UNPKG

ship-components-typeahead

Version:
434 lines (383 loc) 10.2 kB
/** **************************************************************************** * 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;