focus-components-v3
Version:
Focus web components to build applications (based on Material Design)
291 lines (267 loc) • 11.7 kB
JavaScript
import React, {Component, PropTypes} from 'react';
import ReactDOM from 'react-dom';
import MDBehaviour from '../behaviours/material';
import {InputBehaviour} from '../behaviours/input-component';
import i18next from 'i18next';
import closest from 'closest';
import debounce from 'lodash/debounce';
import uniqueId from 'lodash/uniqueId';
const ENTER_KEY_CODE = 13;
const TAB_KEY_CODE = 27;
const UP_ARROW_KEY_CODE = 38;
const DOWN_ARROW_KEY_CODE = 40;
class Autocomplete extends Component {
constructor(props) {
super(props);
const state = {
focus: false,
resolvedValue: this.props.rawInputValue,
options: new Map(),
active: null,
selected: this.props.rawInputValue,
fromKeyResolver: false,
suggestions: [],
isLoading: false,
customError: this.props.customError,
totalCount: 0
};
this.state = state;
this.autocompleteId = uniqueId('autocomplete-text-');
};
componentWillMount() {
this.allowBlur = true;
};
componentDidMount() {
const {rawInputValue, keyResolver, inputTimeout} = this.props;
// rawInputValue is defined, call the keyResolver to get the associated label
if (rawInputValue !== undefined && rawInputValue !== null) {
keyResolver(rawInputValue).then(value => {
if(this.props.rawInputValue !== '') {
this.setState({resolvedValue: value, fromKeyResolver: true})
}
}).catch(error => this.setState({customError: error.message}));
}
document.addEventListener('click', this._handleDocumentClick);
this._debouncedQuerySearcher = debounce(this._querySearcher, inputTimeout);
};
componentWillReceiveProps({rawInputValue, customError, error}) {
const {keyResolver} = this.props;
if (rawInputValue !== this.props.rawInputValue && rawInputValue !== undefined && rawInputValue !== null) { // rawInputValue is defined, call the keyResolver to get the associated label
this.setState({customError}, () => keyResolver(rawInputValue).then(value => {
this.setState({resolvedValue: value, fromKeyResolver: true});
}).catch(error => this.setState({customError: error.message})));
} else if (customError !== this.props.customError) {
this.setState({customError});
}
if(error) {
this.setState({customError: error});
}
};
componentDidUpdate() {
const {valid} = this.props;
if (!valid) {
this.refs.inputText.classList.add('is-invalid');
} else {
this.refs.inputText.classList.remove('is-invalid');
}
};
componentWillUnmount() {
document.removeEventListener('click', this._handleDocumentClick);
};
getValue() {
const {labelName, keyName, rawInputValue} = this.props;
const {resolvedValue, selected, options, fromKeyResolver} = this.state;
const resolvedLabel = options.get(selected);
// The user cleared the field, return a null
if (resolvedValue === '') {
return null;
// Value was received from the keyResolver, give it firectly
} else if (fromKeyResolver) {
return rawInputValue;
// The user typed something without selecting any option, return a null
} else if (resolvedLabel !== resolvedValue && selected !== resolvedValue) {
return null;
// The user selected an option (or no value was provided), return it
} else {
return selected || null;
}
};
_handleDocumentClick = ({target}) => {
const {focus, resolvedValue} = this.state;
const {onBadInput} = this.props;
if (focus) {
const closestACParent = closest(target, `[data-id='${this.autocompleteId}']`, true);
if(closestACParent === undefined) {
this.setState({focus: false}, () => {
if (onBadInput && this.getValue() === null && resolvedValue !== '') {
onBadInput(resolvedValue);
}
});
}
}
};
_handleonBlur = () => {
const {onChange, onBadInput, onBlurError} = this.props;
const {suggestions, options, rawInputValue, resolvedValue, selected, resolvedLabel} = this.state;
if(this.allowBlur) {
if(suggestions.length === 0 && options.size === 1 && resolvedValue !== '' && rawInputValue) {
this.setState({selected: rawInputValue, focus: false, resolvedValue: options.get(rawInputValue)}, () => {
if(onChange) onChange(rawInputValue);
});
} else if(suggestions.length === 1){
this.setState({selected: suggestions[0].key, focus: false, resolvedValue: suggestions[0].label}, () => {
if(onChange) onChange(suggestions[0].key);
});
} else if(this.getValue() === null) {
this.setState({focus: false}, () => {
if (onBadInput && this.getValue() === null && resolvedValue !== '') {
onBadInput(resolvedValue);
}
});
}
}
};
_handleonChange = ({target: {value}}) => {
// the user cleared the input, don't call the querySearcher
if (value === '') {
const {onChange} = this.props;
this.setState({resolvedValue: value, inputValue: value, fromKeyResolver: false});
if (onChange) onChange(null);
} else {
this.setState({resolvedValue: value, inputValue: value, fromKeyResolver: false, isLoading: true});
this._debouncedQuerySearcher(value);
}
};
_querySearcher = value => {
const {querySearcher, keyName, labelName, onChange, onBadInput, onInputChange} = this.props;
querySearcher(value).then(({data, totalCount}) => {
// TODO handle the incomplete option list case
const options = new Map();
data.forEach(item => {
options.set(item[keyName], item[labelName]);
});
this.setState({options, isLoading: false, totalCount, suggestions: data}, () => {
if(data.length === 0) onBadInput(value);
});
}).catch(error => this.setState({customError: error.message}));
};
_handleonFocus = () => {
(this.refs.options || {}).scrollTop = 0;
if (this.props.onFocus) {
this.props.onFocus.call(this);
}
this.setState({active: '', focus: true});
};
_handleonKeyDown = (event) => {
event.stopPropagation();
const {which} = event;
const {active, options} = this.state;
if (which === ENTER_KEY_CODE && active) this._select(active);
if (which === TAB_KEY_CODE) {
this.setState({focus: false});
this.refs.htmlInput.blur();
}
// the user pressed on an arrow key, change the active key
if ([DOWN_ARROW_KEY_CODE, UP_ARROW_KEY_CODE].indexOf(which) !== -1) {
const optionKeys = [];
for (let key of options.keys()) {
optionKeys.push(key);
}
const currentIndex = optionKeys.indexOf(active);
let newIndex = currentIndex + (which === DOWN_ARROW_KEY_CODE ? 1 : -1);
if (newIndex >= options.size) {
newIndex -= options.size
}
if (newIndex < 0) {
newIndex += options.size;
}
this.setState({active: optionKeys[newIndex]});
}
};
_handleSuggestionHover = key => {
this.setState({active: key});
};
_select = (key) => {
const {options} = this.state;
const {onChange, keyName, labelName} = this.props;
const resolvedLabel = options.get(key) || '';
this.allowBlur = false;
this.refs.htmlInput.blur();
this.setState({resolvedValue: i18next.t(resolvedLabel), selected: key, focus: false}, () => {
onChange(key);
});
this.allowBlur = true;
};
_renderOptions = () => {
const {active, options, focus} = this.state;
const renderedOptions = [];
for (let [key, value] of options) {
const isActive = active === key;
renderedOptions.push(
<li
data-active={isActive}
data-focus='option'
key={key}
onMouseDown={this._select.bind(this, key)}
onMouseOver={this._handleSuggestionHover.bind(this, key)}
>
{i18next.t(value)}
</li>
);
}
return (
focus && <ul data-focus='options' ref='options'>{renderedOptions}</ul>
);
};
render () {
const {resolvedValue, isLoading, inputValue} = this.state;
const validInputProps = this._checkProps(this.props);
const { customError, inputTimeout, keyName, keyResolver, hasResolved, labelName, placeholder, querySearcher, renderOptions, valid } = this.props;
validInputProps.value = hasResolved ? resolvedValue: inputValue;
validInputProps.value = validInputProps.value === undefined || validInputProps.value === null ? '' : validInputProps.value;
const cssClass = `mdl-textfield mdl-js-textfield${!valid ? ' is-invalid' : ''}`;
return (
<div data-focus='autocomplete' data-id={this.autocompleteId}>
<div className={cssClass} data-focus='input-text' ref='inputText'>
<div data-focus='loading' data-loading={isLoading} className='mdl-progress mdl-js-progress mdl-progress__indeterminate' ref='loader'></div>
<input
className='mdl-textfield__input'
ref='htmlInput'
type='text'
{...validInputProps}
/>
<label className='mdl-textfield__label'>{i18next.t(placeholder)}</label>
{!valid && <span className='mdl-textfield__error'>{i18next.t(customError)}</span>}
</div>
{renderOptions ? renderOptions.call(this) : this._renderOptions()}
</div>
);
};
}
Autocomplete.displayName = 'Autocomplete';
Autocomplete.propTypes = {
customError: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool
]),
hasResolved: PropTypes.bool,
inputTimeout: PropTypes.number.isRequired,
keyName: PropTypes.string.isRequired,
keyResolver: PropTypes.func.isRequired,
labelName: PropTypes.string.isRequired,
onBadInput: PropTypes.func,
onChange: PropTypes.func.isRequired,
placeholder: PropTypes.string,
querySearcher: PropTypes.func.isRequired,
renderOptions: PropTypes.func,
rawInputValue: PropTypes.string
};
Autocomplete.defaultProps = {
keyName: 'key',
labelName: 'label',
hasResolved: true,
inputTimeout: 200
};
export default Autocomplete;