From 283e7e2ef36f5a8c44f16d08f43a897d732d7eb2 Mon Sep 17 00:00:00 2001 From: Vitaliy Filippov Date: Wed, 1 Aug 2018 15:05:41 +0300 Subject: [PATCH] Rework & simplify autocomplete code --- components/autocomplete/Autocomplete.d.ts | 15 -- components/autocomplete/Autocomplete.js | 256 ++++++++++------------ components/autocomplete/readme.md | 3 - components/autocomplete/theme.css | 12 + 4 files changed, 124 insertions(+), 162 deletions(-) diff --git a/components/autocomplete/Autocomplete.d.ts b/components/autocomplete/Autocomplete.d.ts index 9eddafa1..0b6162d2 100644 --- a/components/autocomplete/Autocomplete.d.ts +++ b/components/autocomplete/Autocomplete.d.ts @@ -42,11 +42,6 @@ export interface AutocompleteTheme { } 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. * @default auto @@ -105,16 +100,6 @@ export interface AutocompleteProps extends InputProps { * @default above */ 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. */ diff --git a/components/autocomplete/Autocomplete.js b/components/autocomplete/Autocomplete.js index 3e01cc2e..53ff427d 100644 --- a/components/autocomplete/Autocomplete.js +++ b/components/autocomplete/Autocomplete.js @@ -4,7 +4,6 @@ import PropTypes from 'prop-types'; import ReactDOM from 'react-dom'; import classnames from 'classnames'; import { themr } from 'react-css-themr'; -import { isValuePresent } from '../utils/utils'; import { AUTOCOMPLETE } from '../identifiers.js'; import InjectChip from '../chip/Chip.js'; import InjectInput from '../input/Input.js'; @@ -24,7 +23,6 @@ const factory = (Chip, Input) => { static propTypes = { allowClear: PropTypes.bool, clearTooltip: PropTypes.string, - allowCreate: PropTypes.bool, className: PropTypes.string, direction: PropTypes.oneOf(['auto', 'up', 'down']), disabled: PropTypes.bool, @@ -37,6 +35,8 @@ const factory = (Chip, Input) => { PropTypes.string, PropTypes.node, ]), + labelKey: PropTypes.string, + valueKey: PropTypes.string, multiple: PropTypes.bool, onBlur: PropTypes.func, onChange: PropTypes.func, @@ -46,9 +46,8 @@ const factory = (Chip, Input) => { onQueryChange: PropTypes.func, query: PropTypes.string, selectedPosition: PropTypes.oneOf(['above', 'below', 'none']), - showSelectedWhenNotInSource: PropTypes.bool, - showSuggestionsWhenValueIsSet: PropTypes.bool, source: PropTypes.any, + minWidth: PropTypes.number, suggestionMatch: PropTypes.oneOf(['disabled', 'start', 'anywhere', 'word', 'none']), theme: PropTypes.shape({ active: PropTypes.string, @@ -67,29 +66,23 @@ const factory = (Chip, Input) => { static defaultProps = { allowClear: false, clearTooltip: 'Clear', - allowCreate: false, className: '', direction: 'auto', keepFocusOnChange: false, multiple: true, selectedPosition: 'above', - showSelectedWhenNotInSource: false, - showSuggestionsWhenValueIsSet: false, source: {}, suggestionMatch: 'start', }; state = { focus: false, - showAllSuggestions: this.props.showSuggestionsWhenValueIsSet, - query: this.props.query ? this.props.query : this.query(this.props.value), - isValueAnObject: false, + query: this.props.query, }; componentWillReceiveProps(nextProps) { - if (!this.props.multiple) { - const query = nextProps.query ? nextProps.query : this.query(nextProps.value); - this.updateQuery(query, false); + if (this.props.query !== nextProps.query) { + this.setState({ query: nextProps.query }); } } @@ -100,57 +93,48 @@ const factory = (Chip, Input) => { 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 }, () => { + 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 }, () => { 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); + updateQuery = (query) => { + if (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: query ? false : this.props.showSuggestionsWhenValueIsSet, active: null }); + handleQueryChange = (value, event) => { + if (value === '' && !this.props.multiple && + (this.props.allowBlank || this.props.allowClear) && this.props.onChange) { + this.props.onChange(null, event); + } + this.updateQuery(value); + this.setState({ active: null }); }; handleQueryFocus = (event) => { event.target.scrollTop = 0; - this.setState({ active: '', focus: true }); + this.setState({ active: null, focus: true }); if (this.props.onFocus) this.props.onFocus(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) { this.selectOrCreateActiveItem(event); } @@ -173,25 +157,40 @@ const factory = (Chip, Input) => { }; 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() { const client = ReactDOM.findDOMNode(this.inputNode).getBoundingClientRect(); + const screen_width = window.innerWidth || document.documentElement.offsetWidth; const screen_height = window.innerHeight || document.documentElement.offsetHeight; let direction = this.props.direction; if (this.props.direction === 'auto') { const up = client.top > ((screen_height / 2) + client.height); 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 left = client.left+'px'; - const width = client.width+'px'; + 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); } + 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 || @@ -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) { let target = this.state.active; - if (!target) { - target = this.props.allowCreate - ? this.state.query - : [...this.suggestions().keys()][0]; - this.setState({ active: target }); + if (target == null) { + for (let k of this.suggestions().keys()) { + this.setState({ active: k }); + break; + } + } else { + this.select(event, target); } - this.select(event, target); } normalise(value) { @@ -238,30 +229,23 @@ const factory = (Chip, Input) => { 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(); + const query = this.normalise(this.state.query == null ? '' : this.state.query+''); - // Suggest any non-set value which matches the query - 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) { + if (query !== '' && this.props.suggestionMatch !== 'disabled' && this.props.suggestionMatch !== 'none') { for (const [key, value] of source) { 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 { - 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; @@ -270,108 +254,88 @@ const factory = (Chip, Input) => { matches(value, query) { const { suggestionMatch } = this.props; - if (suggestionMatch === 'disabled') { - return true; - } else if (suggestionMatch === 'start') { + 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); - }else if(suggestionMatch === 'none'){ - return 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])); + 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]])); + } } - 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; + return this._source; } 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; - + let 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; - }, {}); - - if (Object.keys(newItem).length === 0 && newValue) { - newItem[newValue] = newValue; - } - - return this.handleChange(Object.assign(this.mapToObject(values), newItem), event); + newValue = { ...(this.props.value||{}), [newValue]: true }; + } else if (this.props.multiple) { + newValue = [ ...(this.props.value||[]), newValue ]; } - - this.handleChange([newValue, ...values.keys()], event); - }; + this.handleChange(newValue, event); + } unselect(key, event) { if (!this.props.disabled) { - const values = this.values(this.props.value); - - values.delete(key); - + let newValue; 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([...values.keys()], event); + this.handleChange(newValue, event); } } 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) { - return Array.from(map).reduce((obj, [k, value]) => { - obj[k] = value; + mapToObject(array) { + return array.reduce((obj, k) => { + obj[k] = true; return obj; }, {}); } renderSelected() { 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 => ( - {value} + {source.get(''+key)} )); @@ -415,26 +379,30 @@ const factory = (Chip, Input) => { render() { const { - allowClear, allowCreate, clearTooltip, error, label, source, suggestionMatch, query, // eslint-disable-line no-unused-vars - selectedPosition, keepFocusOnChange, showSuggestionsWhenValueIsSet, showSelectedWhenNotInSource, onQueryChange, // eslint-disable-line no-unused-vars - theme, ...other + allowClear, clearTooltip, error, label, source, suggestionMatch, query, // eslint-disable-line no-unused-vars + selectedPosition, keepFocusOnChange, onQueryChange, // eslint-disable-line no-unused-vars + theme, multiple, minWidth, ...other } = this.props; + // outdated properties + delete other.showSelectedWhenNotInSource; + delete other.allowCreate; const className = classnames(theme.autocomplete, { [theme.focus]: this.state.focus, }, this.props.className); + const withClear = allowClear && (this.props.multiple ? this.props.value && Object.keys(this.props.value).length > 0 : this.props.value != null); return (
{selectedPosition === 'above' ? this.renderSelected() : null} - {allowClear && this.state.query != '' ? this.handleChange([], e)}>clear : null} + onClick={e => this.handleChange(multiple ? [] : null, e)}>clear : null} { this.inputNode = node; }} autoComplete="off" - className={theme.input+(allowClear && this.state.query != '' ? ' '+theme.withclear : '')} + className={theme.input+(withClear ? ' '+theme.withclear : '')} error={error} label={label} onBlur={this.handleQueryBlur} @@ -444,7 +412,7 @@ const factory = (Chip, Input) => { onKeyUp={this.handleQueryKeyUp} theme={theme} 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} /> {this.state.focus ? this.renderSuggestionList() : null} diff --git a/components/autocomplete/readme.md b/components/autocomplete/readme.md index cf87d226..ea66c142 100644 --- a/components/autocomplete/readme.md +++ b/components/autocomplete/readme.md @@ -43,7 +43,6 @@ If you want to provide a theme via context, the component key is `RTAutocomplete | 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.| | `direction` | `String` | `auto` | Determines the opening direction. It can be `auto`, `up` or `down`. | | `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. | | `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`. | -| `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). | | `value` | `String`, `Array` or `Object` | | Value or array of values currently selected component. | diff --git a/components/autocomplete/theme.css b/components/autocomplete/theme.css index 03b0122f..7e9c7d51 100644 --- a/components/autocomplete/theme.css +++ b/components/autocomplete/theme.css @@ -24,6 +24,18 @@ 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 { flex-direction: row; flex-wrap: wrap;