Rework & simplify autocomplete code

dev
Vitaliy Filippov 2018-08-01 15:05:41 +03:00
parent daf6bac933
commit 283e7e2ef3
4 changed files with 124 additions and 162 deletions

View File

@ -42,11 +42,6 @@ export interface AutocompleteTheme {
} }
export interface AutocompleteProps extends InputProps { export interface AutocompleteProps extends InputProps {
/**
* Determines if user can create a new option with the current typed value.
* @default false
*/
allowCreate?: boolean;
/** /**
* Determines the opening direction. It can be auto, up or down. * Determines the opening direction. It can be auto, up or down.
* @default auto * @default auto
@ -105,16 +100,6 @@ export interface AutocompleteProps extends InputProps {
* @default above * @default above
*/ */
selectedPosition?: "above" | "below" | "none"; selectedPosition?: "above" | "below" | "none";
/**
* Determines if the selected list is shown if the `value` keys don't exist in the source. Only works if passing the `value` prop as an Object.
* @default false
*/
showSelectedWhenNotInSource?: boolean;
/**
* If true, the list of suggestions will not be filtered when a value is selected.
* @default false
*/
showSuggestionsWhenValueIsSet?: boolean;
/** /**
* Object of key/values or array representing all items suggested. * Object of key/values or array representing all items suggested.
*/ */

View File

@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import classnames from 'classnames'; import classnames from 'classnames';
import { themr } from 'react-css-themr'; import { themr } from 'react-css-themr';
import { isValuePresent } from '../utils/utils';
import { AUTOCOMPLETE } from '../identifiers.js'; import { AUTOCOMPLETE } from '../identifiers.js';
import InjectChip from '../chip/Chip.js'; import InjectChip from '../chip/Chip.js';
import InjectInput from '../input/Input.js'; import InjectInput from '../input/Input.js';
@ -24,7 +23,6 @@ const factory = (Chip, Input) => {
static propTypes = { static propTypes = {
allowClear: PropTypes.bool, allowClear: PropTypes.bool,
clearTooltip: PropTypes.string, clearTooltip: PropTypes.string,
allowCreate: PropTypes.bool,
className: PropTypes.string, className: PropTypes.string,
direction: PropTypes.oneOf(['auto', 'up', 'down']), direction: PropTypes.oneOf(['auto', 'up', 'down']),
disabled: PropTypes.bool, disabled: PropTypes.bool,
@ -37,6 +35,8 @@ const factory = (Chip, Input) => {
PropTypes.string, PropTypes.string,
PropTypes.node, PropTypes.node,
]), ]),
labelKey: PropTypes.string,
valueKey: PropTypes.string,
multiple: PropTypes.bool, multiple: PropTypes.bool,
onBlur: PropTypes.func, onBlur: PropTypes.func,
onChange: PropTypes.func, onChange: PropTypes.func,
@ -46,9 +46,8 @@ const factory = (Chip, Input) => {
onQueryChange: PropTypes.func, onQueryChange: PropTypes.func,
query: PropTypes.string, query: PropTypes.string,
selectedPosition: PropTypes.oneOf(['above', 'below', 'none']), selectedPosition: PropTypes.oneOf(['above', 'below', 'none']),
showSelectedWhenNotInSource: PropTypes.bool,
showSuggestionsWhenValueIsSet: PropTypes.bool,
source: PropTypes.any, source: PropTypes.any,
minWidth: PropTypes.number,
suggestionMatch: PropTypes.oneOf(['disabled', 'start', 'anywhere', 'word', 'none']), suggestionMatch: PropTypes.oneOf(['disabled', 'start', 'anywhere', 'word', 'none']),
theme: PropTypes.shape({ theme: PropTypes.shape({
active: PropTypes.string, active: PropTypes.string,
@ -67,29 +66,23 @@ const factory = (Chip, Input) => {
static defaultProps = { static defaultProps = {
allowClear: false, allowClear: false,
clearTooltip: 'Clear', clearTooltip: 'Clear',
allowCreate: false,
className: '', className: '',
direction: 'auto', direction: 'auto',
keepFocusOnChange: false, keepFocusOnChange: false,
multiple: true, multiple: true,
selectedPosition: 'above', selectedPosition: 'above',
showSelectedWhenNotInSource: false,
showSuggestionsWhenValueIsSet: false,
source: {}, source: {},
suggestionMatch: 'start', suggestionMatch: 'start',
}; };
state = { state = {
focus: false, focus: false,
showAllSuggestions: this.props.showSuggestionsWhenValueIsSet, query: this.props.query,
query: this.props.query ? this.props.query : this.query(this.props.value),
isValueAnObject: false,
}; };
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
if (!this.props.multiple) { if (this.props.query !== nextProps.query) {
const query = nextProps.query ? nextProps.query : this.query(nextProps.value); this.setState({ query: nextProps.query });
this.updateQuery(query, false);
} }
} }
@ -100,57 +93,48 @@ const factory = (Chip, Input) => {
return true; return true;
} }
handleChange = (values, event) => { handleChange = (value, event) => {
const value = this.props.multiple ? values : values[0]; if (this.props.onChange) {
const { showSuggestionsWhenValueIsSet: showAllSuggestions } = this.props; this.props.onChange(value, event);
const query = this.query(value); }
if (this.props.onChange) this.props.onChange(value, event); if (!this.props.keepFocusOnChange) {
if (this.props.keepFocusOnChange) { this.updateQuery(undefined);
this.setState({ query, showAllSuggestions }); this.setState({ query: undefined, focus: false }, () => {
} else {
this.setState({ focus: false, query, showAllSuggestions }, () => {
ReactDOM.findDOMNode(this).querySelector('input').blur(); ReactDOM.findDOMNode(this).querySelector('input').blur();
}); });
} }
this.updateQuery(query, this.props.query);
}; };
handleMouseDown = (event) => { handleMouseDown = (event) => {
this.selectOrCreateActiveItem(event); this.selectOrCreateActiveItem(event);
} };
handleQueryBlur = (event) => { handleQueryBlur = (event) => {
if (this.state.focus) this.setState({ focus: false }); if (this.state.focus) this.setState({ focus: false });
if (this.props.onBlur) this.props.onBlur(event, this.state.active); if (this.props.onBlur) this.props.onBlur(event, this.state.active);
}; };
updateQuery = (query, notify) => { updateQuery = (query) => {
if (notify && this.props.onQueryChange) this.props.onQueryChange(query); if (this.props.onQueryChange) this.props.onQueryChange(query);
this.setState({ query }); this.setState({ query });
} };
handleQueryChange = (value) => { handleQueryChange = (value, event) => {
const query = this.clearQuery ? '' : value; if (value === '' && !this.props.multiple &&
this.clearQuery = false; (this.props.allowBlank || this.props.allowClear) && this.props.onChange) {
this.props.onChange(null, event);
this.updateQuery(query, true); }
this.setState({ showAllSuggestions: query ? false : this.props.showSuggestionsWhenValueIsSet, active: null }); this.updateQuery(value);
this.setState({ active: null });
}; };
handleQueryFocus = (event) => { handleQueryFocus = (event) => {
event.target.scrollTop = 0; event.target.scrollTop = 0;
this.setState({ active: '', focus: true }); this.setState({ active: null, focus: true });
if (this.props.onFocus) this.props.onFocus(event); if (this.props.onFocus) this.props.onFocus(event);
}; };
handleQueryKeyDown = (event) => { handleQueryKeyDown = (event) => {
// Mark query for clearing in handleQueryChange when pressing backspace and showing all suggestions.
this.clearQuery = (
event.which === 8
&& this.props.showSuggestionsWhenValueIsSet
&& this.state.showAllSuggestions
);
if (event.which === 13) { if (event.which === 13) {
this.selectOrCreateActiveItem(event); this.selectOrCreateActiveItem(event);
} }
@ -173,25 +157,40 @@ const factory = (Chip, Input) => {
}; };
handleSuggestionHover = (event) => { handleSuggestionHover = (event) => {
this.setState({ active: event.target.id }); let t = event.target;
while (t && !t.id) {
t = t.parentNode;
}
this.setState({ active: t && t.id || '' });
}; };
calculateDirection() { calculateDirection() {
const client = ReactDOM.findDOMNode(this.inputNode).getBoundingClientRect(); const client = ReactDOM.findDOMNode(this.inputNode).getBoundingClientRect();
const screen_width = window.innerWidth || document.documentElement.offsetWidth;
const screen_height = window.innerHeight || document.documentElement.offsetHeight; const screen_height = window.innerHeight || document.documentElement.offsetHeight;
let direction = this.props.direction; let direction = this.props.direction;
if (this.props.direction === 'auto') { if (this.props.direction === 'auto') {
const up = client.top > ((screen_height / 2) + client.height); const up = client.top > ((screen_height / 2) + client.height);
direction = up ? 'up' : 'down'; direction = up ? 'up' : 'down';
} }
const top = direction == 'down' ? (client.top+client.height)+'px' : client.top+'px'; 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; const bottom = direction == 'up' ? '0px' : undefined;
const left = client.left+'px'; let left = (client.left
const width = client.width+'px'; + (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; let maxHeight = direction == 'down' ? (screen_height-client.top-client.height) : client.top;
if (maxHeight > screen_height*0.45) { if (maxHeight > screen_height*0.45) {
maxHeight = Math.floor(screen_height*0.45); maxHeight = Math.floor(screen_height*0.45);
} }
left = left+'px';
width = width+'px';
maxHeight = maxHeight+'px'; maxHeight = maxHeight+'px';
if (this.state.top !== top || this.state.left !== left || if (this.state.top !== top || this.state.left !== left ||
this.state.width !== width || this.state.bottom !== bottom || this.state.width !== width || this.state.bottom !== bottom ||
@ -200,24 +199,16 @@ const factory = (Chip, Input) => {
} }
} }
query(key) {
let query_value = '';
if (!this.props.multiple && isValuePresent(key)) {
const source_value = this.source().get(`${key}`);
query_value = source_value || key;
}
return query_value;
}
selectOrCreateActiveItem(event) { selectOrCreateActiveItem(event) {
let target = this.state.active; let target = this.state.active;
if (!target) { if (target == null) {
target = this.props.allowCreate for (let k of this.suggestions().keys()) {
? this.state.query this.setState({ active: k });
: [...this.suggestions().keys()][0]; break;
this.setState({ active: target }); }
} else {
this.select(event, target);
} }
this.select(event, target);
} }
normalise(value) { normalise(value) {
@ -238,30 +229,23 @@ const factory = (Chip, Input) => {
suggestions() { suggestions() {
let suggest = new Map(); 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(); const source = this.source();
const query = this.normalise(this.state.query == null ? '' : this.state.query+'');
// Suggest any non-set value which matches the query if (query !== '' && this.props.suggestionMatch !== 'disabled' && this.props.suggestionMatch !== 'none') {
if (this.props.multiple) {
for (const [key, value] of source) {
if (!values.has(key) && this.matches(this.normalise(value), query)) {
suggest.set(key, value);
}
}
// When multiple is false, suggest any value which matches the query if showAllSuggestions is false
} else if (query && !this.state.showAllSuggestions) {
for (const [key, value] of source) { for (const [key, value] of source) {
if (this.matches(this.normalise(value), query)) { if (this.matches(this.normalise(value), query)) {
suggest.set(key, value); suggest.set(''+key, value);
} }
} }
// When multiple is false, suggest all values when showAllSuggestions is true
} else { } else {
suggest = source; 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);
}
} }
return suggest; return suggest;
@ -270,108 +254,88 @@ const factory = (Chip, Input) => {
matches(value, query) { matches(value, query) {
const { suggestionMatch } = this.props; const { suggestionMatch } = this.props;
if (suggestionMatch === 'disabled') { if (suggestionMatch === 'start') {
return true;
} else if (suggestionMatch === 'start') {
return value.startsWith(query); return value.startsWith(query);
} else if (suggestionMatch === 'anywhere') { } else if (suggestionMatch === 'anywhere') {
return value.includes(query); return value.includes(query);
} else if (suggestionMatch === 'word') { } else if (suggestionMatch === 'word') {
const re = new RegExp(`\\b${query}`, 'g'); const re = new RegExp(`\\b${query}`, 'g');
return re.test(value); return re.test(value);
}else if(suggestionMatch === 'none'){
return value
} }
return false; return false;
} }
source() { source() {
const { source: src } = this.props; const src = this.props.source;
if (src.hasOwnProperty('length')) { if (this._cachedSource != src) {
return new Map(src.map(item => Array.isArray(item) ? [...item] : [item, item])); 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]]));
}
} }
return new Map(Object.keys(src).map(key => [`${key}`, src[key]])); return this._source;
}
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) => { select = (event, target) => {
events.pauseEvent(event); events.pauseEvent(event);
const values = this.values(this.props.value); let newValue = target === void 0 ? event.target.id : target;
const source = this.source();
const newValue = target === void 0 ? event.target.id : target;
if (this.isValueAnObject()) { if (this.isValueAnObject()) {
const newItem = Array.from(source).reduce((obj, [k, value]) => { newValue = { ...(this.props.value||{}), [newValue]: true };
if (k === newValue) { } else if (this.props.multiple) {
obj[k] = value; newValue = [ ...(this.props.value||[]), newValue ];
}
return obj;
}, {});
if (Object.keys(newItem).length === 0 && newValue) {
newItem[newValue] = newValue;
}
return this.handleChange(Object.assign(this.mapToObject(values), newItem), event);
} }
this.handleChange(newValue, event);
this.handleChange([newValue, ...values.keys()], event); }
};
unselect(key, event) { unselect(key, event) {
if (!this.props.disabled) { if (!this.props.disabled) {
const values = this.values(this.props.value); let newValue;
values.delete(key);
if (this.isValueAnObject()) { if (this.isValueAnObject()) {
return this.handleChange(this.mapToObject(values), event); newValue = { ...this.props.value };
delete newValue[key];
} else if (this.props.multiple) {
newValue = (this.props.value||[]).filter(v => v != key);
} }
this.handleChange(newValue, event);
this.handleChange([...values.keys()], event);
} }
} }
isValueAnObject() { isValueAnObject() {
return !Array.isArray(this.props.value) && typeof this.props.value === 'object'; return this.props.value && !Array.isArray(this.props.value) && typeof this.props.value === 'object';
} }
mapToObject(map) { mapToObject(array) {
return Array.from(map).reduce((obj, [k, value]) => { return array.reduce((obj, k) => {
obj[k] = value; obj[k] = true;
return obj; return obj;
}, {}); }, {});
} }
renderSelected() { renderSelected() {
if (this.props.multiple) { if (this.props.multiple) {
const selectedItems = [...this.values()].map(([key, value]) => ( const values = this.isValueAnObject() ? Object.keys(this.props.value) : this.props.value||[];
const source = this.source();
const selectedItems = values.map(key => (
<Chip <Chip
key={key} key={key}
className={this.props.theme.value} className={this.props.theme.value}
deletable deletable
onDeleteClick={this.unselect.bind(this, key)} onDeleteClick={this.unselect.bind(this, key)}
> >
{value} {source.get(''+key)}
</Chip> </Chip>
)); ));
@ -415,26 +379,30 @@ const factory = (Chip, Input) => {
render() { render() {
const { const {
allowClear, allowCreate, clearTooltip, error, label, source, suggestionMatch, query, // eslint-disable-line no-unused-vars allowClear, clearTooltip, error, label, source, suggestionMatch, query, // eslint-disable-line no-unused-vars
selectedPosition, keepFocusOnChange, showSuggestionsWhenValueIsSet, showSelectedWhenNotInSource, onQueryChange, // eslint-disable-line no-unused-vars selectedPosition, keepFocusOnChange, onQueryChange, // eslint-disable-line no-unused-vars
theme, ...other theme, multiple, minWidth, ...other
} = this.props; } = this.props;
// outdated properties
delete other.showSelectedWhenNotInSource;
delete other.allowCreate;
const className = classnames(theme.autocomplete, { const className = classnames(theme.autocomplete, {
[theme.focus]: this.state.focus, [theme.focus]: this.state.focus,
}, this.props.className); }, this.props.className);
const withClear = allowClear && (this.props.multiple ? this.props.value && Object.keys(this.props.value).length > 0 : this.props.value != null);
return ( return (
<div data-react-toolbox="autocomplete" className={className}> <div data-react-toolbox="autocomplete" className={className}>
{selectedPosition === 'above' ? this.renderSelected() : null} {selectedPosition === 'above' ? this.renderSelected() : null}
{allowClear && this.state.query != '' ? <span {withClear ? <span
className={'material-icons '+theme.clear} className={'material-icons '+theme.clear}
title={clearTooltip} title={clearTooltip}
onClick={(e) => this.handleChange([], e)}>clear</span> : null} onClick={e => this.handleChange(multiple ? [] : null, e)}>clear</span> : null}
<Input <Input
{...other} {...other}
ref={(node) => { this.inputNode = node; }} ref={(node) => { this.inputNode = node; }}
autoComplete="off" autoComplete="off"
className={theme.input+(allowClear && this.state.query != '' ? ' '+theme.withclear : '')} className={theme.input+(withClear ? ' '+theme.withclear : '')}
error={error} error={error}
label={label} label={label}
onBlur={this.handleQueryBlur} onBlur={this.handleQueryBlur}
@ -444,7 +412,7 @@ const factory = (Chip, Input) => {
onKeyUp={this.handleQueryKeyUp} onKeyUp={this.handleQueryKeyUp}
theme={theme} theme={theme}
themeNamespace="input" themeNamespace="input"
value={this.state.query} value={this.state.query == null ? (this.props.multiple || !this.props.value ? '' : this.source().get(''+this.props.value)) : this.state.query}
/> />
<Portal> <Portal>
{this.state.focus ? this.renderSuggestionList() : null} {this.state.focus ? this.renderSuggestionList() : null}

View File

@ -43,7 +43,6 @@ If you want to provide a theme via context, the component key is `RTAutocomplete
| Name | Type | Default | Description| | Name | Type | Default | Description|
|:-----|:-----|:-----|:-----| |:-----|:-----|:-----|:-----|
| `allowCreate` | `Bool` | `false` | Determines if user can create a new option with the current typed value |
| `className` | `String` | `''` | Sets a class to style of the Component.| | `className` | `String` | `''` | Sets a class to style of the Component.|
| `direction` | `String` | `auto` | Determines the opening direction. It can be `auto`, `up` or `down`. | | `direction` | `String` | `auto` | Determines the opening direction. It can be `auto`, `up` or `down`. |
| `disabled` | `Bool` | `false` | If true, component will be disabled. | | `disabled` | `Bool` | `false` | If true, component will be disabled. |
@ -60,8 +59,6 @@ If you want to provide a theme via context, the component key is `RTAutocomplete
| `query` | `String` | | This property has to be used in case the `source` is not static and will be changing during search for `multiple={false}` autocomplete, content of the `query` has to be managed by the `onQueryChange` callback. | | `query` | `String` | | This property has to be used in case the `source` is not static and will be changing during search for `multiple={false}` autocomplete, content of the `query` has to be managed by the `onQueryChange` callback. |
| `source` | `Object` or `Array` | | Object of key/values or array representing all items suggested. | | `source` | `Object` or `Array` | | Object of key/values or array representing all items suggested. |
| `selectedPosition` | `String` | `above` | Determines if the selected list is shown above or below input. It can be `above`, `below` or `none`. | | `selectedPosition` | `String` | `above` | Determines if the selected list is shown above or below input. It can be `above`, `below` or `none`. |
| `showSelectedWhenNotInSource` | `Bool` | `false` | Determines if the selected list is shown if the `value` keys don't exist in the source. Only works if passing the `value` prop as an Object. |
| `showSuggestionsWhenValueIsSet` | `Bool` | `false` | If true, the list of suggestions will not be filtered when a value is selected, until the query is modified. |
| `suggestionMatch` | `String` | `start` | Determines how suggestions are supplied. It can be `start` (query matches the start of a suggestion), `anywhere` (query matches anywhere inside the suggestion), `word` (query matches the start of a word in the suggestion) or `disabled` (disable filtering of provided source, all items are shown). | | `suggestionMatch` | `String` | `start` | Determines how suggestions are supplied. It can be `start` (query matches the start of a suggestion), `anywhere` (query matches anywhere inside the suggestion), `word` (query matches the start of a word in the suggestion) or `disabled` (disable filtering of provided source, all items are shown). |
| `value` | `String`, `Array` or `Object` | | Value or array of values currently selected component. | | `value` | `String`, `Array` or `Object` | | Value or array of values currently selected component. |

View File

@ -24,6 +24,18 @@
text-indent: 28px !important; text-indent: 28px !important;
} }
.withclear label {
transition-property: top, left, font-size, color !important;
}
.inputFilled {
composes: filled from '../input/theme.css';
}
.withclear input:not(:focus):not(.inputFilled) ~ label {
left: 28px !important;
}
.values { .values {
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;