ship-components-typeahead
Version:
Material Design React Typeahead Component
434 lines (383 loc) • 10.2 kB
JSX
/** ****************************************************************************
* Typeahead
*
* @author Isaac Suttell <isaac_suttell@playstation.sony.com>
* @file As the user types, show a list of options
******************************************************************************/
// Modules
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import fuzzy from 'fuzzy';
import TextInput from 'ship-components-textinput';
import OutsideClick from 'ship-components-outsideclick';
// Components
import TypeaheadList from './TypeaheadList';
import css from './typeahead.css';
class Typeahead extends React.Component {
constructor(props) {
super(props);
this.state = {
hide: true,
visible: this.getResults.call(this, props.value, props.options),
currentValue: props.value,
selected: 0
};
// Ensure proper context
const bindFn = [
'getResults',
'handleChange',
'handleSelected',
'handleHide',
'keyEvent',
'handleKeyDown',
'handleBlur',
'_onEnter',
'_onUp',
'_onDown',
'handleValidate',
'handleOutsideClick'
];
bindFn.forEach(fn => this[fn] = this[fn].bind(this));
}
componentDidMount() {
if (this.props.scrollParentClass) {
window.addEventListener('resize', this.handleHide);
}
}
/**
* Update selection if options change
*
* @param {Object} nextProps
*/
componentWillReceiveProps(nextProps) {
let { currentValue } = this.state;
if (nextProps.value !== this.props.value) {
currentValue = nextProps.value;
}
// Get new results
const visible = this.getResults(currentValue, nextProps.options);
this.setState({
currentValue,
visible,
selected: 0
});
}
componentWillUnmount() {
if (this.props.scrollParentClass) {
window.removeEventListener('resize', this.handleHide)
}
}
handleHide() {
this.setState({
hide: true
});
}
/**
* Search get fuzzy search results
*
* @param {String} value
* @param {Array<string>} options
* @return {Array<object>}
*/
getResults(currentValue, options) {
if (typeof currentValue === 'undefined') {
currentValue = '';
}
let results = fuzzy.filter(currentValue.toString() || '', options, {
pre: '<span class=\'' + this.props.matchedClass + '\'>',
post: '</span>',
extract: this.props.extract
});
// If we have an exact match, move it to the top
let exactIndex = results.findIndex(result => result.original.value === currentValue);
if (exactIndex > -1) {
let exacted = results.splice(exactIndex, 1)[0];
results.unshift(exacted);
}
if (this.props.maxVisible && this.props.maxVisible > 0) {
results = results.slice(0, this.props.maxVisible);
}
return results;
}
/**
* Update the search results and parent
*/
handleChange(event, callback) {
let state = {
currentValue: event.target.value || '',
selected: 0
};
// Get new results
state.visible = state.currentValue.length > 0 ? this.getResults(state.currentValue, this.props.options) : [];
state.hide = false;
if (state.visible.length === 1) {
state.hide = state.visible[0].original.value === event.target.value;
}
this.setState(state, () => {
if (typeof this.props.onChange === 'function') {
this.props.onChange({
target: {
value: this.state.currentValue
}
});
}
if (typeof callback === 'function') {
callback();
}
});
}
/**
* Handle list item clicks
*
* @param {Object} option
*/
handleSelected(option, event) {
event.stopPropagation();
if (typeof option !== 'object') {
throw new TypeError('Option is not an object');
}
if (this.state.currentValue.length === '' || this.state.visible.length === 0) {
return;
}
const ev = {
target: {
value: option.original.value
}
};
this.handleChange(ev, () => {
if (typeof this.props.onSelected === 'function') {
this.props.onSelected(option, event);
}
let state = {
hide: true
};
if (this.props.clearOnSelect) {
state.currentValue = '';
state.selected = 0;
state.visible = [];
}
this.setState(state);
});
}
/**
* Associate a function handler depending on the keypress
*
* @param {string} keyName
* @return {Function}
*/
keyEvent(keyName) {
switch (keyName) {
case 'Enter':
// if menu is hidden, do normal tab behavior
return this.state.hide ? void 0 : this._onEnter;
case 'ArrowDown':
case 'Down':
return this._onDown;
case 'ArrowUp':
case 'Up':
return this._onUp;
default:
return void 9;
}
}
/**
* Activate the currently selected item
*/
_onEnter(event) {
if (this.state.visible[this.state.selected]) {
this.handleSelected(this.getSelected(), event);
}
}
/**
* Return the active selection
*/
getSelected() {
return this.state.visible[this.state.selected];
}
/**
* Event to move the selection up the list
*/
_onUp() {
const current = this.state.selected;
this.setState({
selected: current > 0 ? current - 1 : 0
});
}
/**
* Event to move the selection down the list
*/
_onDown() {
const current = this.state.selected;
const max = this.state.visible.length - 1;
this.setState({
selected: current < max ? current + 1 : max
});
}
stopHiding() {
if (this.state.hide === true) {
this.setState({
hide: false
});
}
}
/**
* Function to help ignore special key strokes
* @param {String} keyName
* @return {Boolean}
*/
isSpecialKey(keyName) {
return ['Alt', 'CapsLock', 'Control', 'Fn', 'Meta', 'Shift', 'Tab'].includes(keyName);
}
/**
* Call any associated key events
*
* @param {Event} event
*/
handleKeyDown(event) {
if (!this.isSpecialKey(event.key)) {
this.stopHiding();
}
const handler = this.keyEvent(event.key);
if (typeof handler === 'function') {
event.preventDefault();
handler.call(this, event);
}
if (typeof this.props.onKeyDown === 'function') {
this.props.onKeyDown(event);
}
}
handleOutsideClick() {
this.setState({
hide: true
});
}
handleBlur(event) {
if (typeof this.props.onBlur === 'function') {
this.props.onBlur(event);
}
this.setState({
hide: true
});
}
renderEmpty(classes) {
return (
<div className={classes}>
<div className='typeahead--container'>
<div className='typeahead--input form-input'>
{this.state.currentValue}
</div>
</div>
</div>
);
}
handleValidate(value) {
if (typeof this.props.validate === 'function') {
return this.props.validate(value, this.getSelected());
}
return true;
}
getCurrentValue() {
const currentOption = this.props.options.find((option) => {
return option.value === this.state.currentValue;
});
const currentValue = currentOption && currentOption.name ? currentOption.name : this.state.currentValue;
return currentValue;
}
/**
* Render
*
* @return {Render}
*/
render() {
let classes = classNames(
'typeahead',
this.props.className, {
[css.editable]: this.props.editable,
'typeahead--editable': this.props.editable
}
);
if (!this.props.editable) {
return this.renderEmpty(classes);
}
// Type checking to prevent React 15 warning
let customProps = null;
if (this.refs.textInput && typeof this.refs.textInput === 'object') {
customProps = {
editable: true,
validate: this.handleValidate,
minRows: 1,
maxRows: 1,
error: this.props.error
};
}
return (
<div className={classes}>
<OutsideClick
className={classNames('typeahead--container', css.container)}
onClick={this.handleOutsideClick.bind(this)}
>
<div>
<TextInput
ref='textInput'
className={classNames('typeahead--input', css.input)}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
onBlur={this.handleBlur}
onFocus={this.props.onFocus}
value={this.getCurrentValue()}
label={this.props.placeholder}
{...customProps}
/>
{this.props.isLoading ?
<span className={classNames('icon-refresh', css.loading)} />
: null}
</div>
<TypeaheadList
scrollingParentClass={this.props.scrollParentClass}
onScrollingParentScroll={this.handleHide}
hidden={this.state.hide}
empty={this.state.hide || this.props.isLoading ? void 0 : this.props.empty}
selected={this.state.selected}
value={this.state.currentValue}
extract={this.props.extract}
visible={this.state.hide ? [] : this.state.visible}
onSelected={this.handleSelected}
/>
</OutsideClick>
</div>
);
}
}
// Type checking
const { number, string, array, bool, func } = PropTypes;
Typeahead.propTypes = {
maxVisible: number,
matchedClass: string,
placeholder: string,
value: string,
label: string,
options: array,
clearOnSelect: bool,
editable: bool,
empty: bool,
isLoading: bool,
extract: func,
onChange: func
}
Typeahead.defaultProps = {
isLoading: false,
editable: true,
empty: false,
options: [],
label: '',
value: '',
placeholder: '',
maxVisible: 5,
clearOnSelect: false,
matchedClass: 'typeahead-found',
extract: function (item) {
return item;
}
}
export default Typeahead;