UNPKG

react-typeahead

Version:

React-based typeahead and typeahead-tokenizer

418 lines (363 loc) 12.3 kB
var Accessor = require('../accessor'); var React = require('react'); var TypeaheadSelector = require('./selector'); var KeyEvent = require('../keyevent'); var fuzzy = require('fuzzy'); var classNames = require('classnames'); var createReactClass = require('create-react-class'); var PropTypes = require('prop-types'); /** * A "typeahead", an auto-completing text input * * Renders an text input that shows options nearby that you can use the * keyboard or mouse to select. Requires CSS for MASSIVE DAMAGE. */ var Typeahead = createReactClass({ propTypes: { name: PropTypes.string, customClasses: PropTypes.object, maxVisible: PropTypes.number, resultsTruncatedMessage: PropTypes.string, options: PropTypes.array, allowCustomValues: PropTypes.number, initialValue: PropTypes.string, value: PropTypes.string, placeholder: PropTypes.string, disabled: PropTypes.bool, textarea: PropTypes.bool, inputProps: PropTypes.object, onOptionSelected: PropTypes.func, onChange: PropTypes.func, onKeyDown: PropTypes.func, onKeyPress: PropTypes.func, onKeyUp: PropTypes.func, onFocus: PropTypes.func, onBlur: PropTypes.func, filterOption: PropTypes.oneOfType([ PropTypes.string, PropTypes.func ]), searchOptions: PropTypes.func, displayOption: PropTypes.oneOfType([ PropTypes.string, PropTypes.func ]), inputDisplayOption: PropTypes.oneOfType([ PropTypes.string, PropTypes.func ]), formInputOption: PropTypes.oneOfType([ PropTypes.string, PropTypes.func ]), defaultClassNames: PropTypes.bool, customListComponent: PropTypes.oneOfType([ PropTypes.element, PropTypes.func ]), showOptionsWhenEmpty: PropTypes.bool }, getDefaultProps: function() { return { options: [], customClasses: {}, allowCustomValues: 0, initialValue: "", value: "", placeholder: "", disabled: false, textarea: false, inputProps: {}, onOptionSelected: function(option) {}, onChange: function(event) {}, onKeyDown: function(event) {}, onKeyPress: function(event) {}, onKeyUp: function(event) {}, onFocus: function(event) {}, onBlur: function(event) {}, filterOption: null, searchOptions: null, inputDisplayOption: null, defaultClassNames: true, customListComponent: TypeaheadSelector, showOptionsWhenEmpty: false, resultsTruncatedMessage: null }; }, getInitialState: function() { return { // The options matching the entry value searchResults: this.getOptionsForValue(this.props.initialValue, this.props.options), // This should be called something else, "entryValue" entryValue: this.props.value || this.props.initialValue, // A valid typeahead value selection: this.props.value, // Index of the selection selectionIndex: null, // Keep track of the focus state of the input element, to determine // whether to show options when empty (if showOptionsWhenEmpty is true) isFocused: false, // true when focused, false onOptionSelected showResults: false }; }, _shouldSkipSearch: function(input) { var emptyValue = !input || input.trim().length == 0; // this.state must be checked because it may not be defined yet if this function // is called from within getInitialState var isFocused = this.state && this.state.isFocused; return !(this.props.showOptionsWhenEmpty && isFocused) && emptyValue; }, getOptionsForValue: function(value, options) { if (this._shouldSkipSearch(value)) { return []; } var searchOptions = this._generateSearchFunction(); return searchOptions(value, options); }, setEntryText: function(value) { this.refs.entry.value = value; this._onTextEntryUpdated(); }, focus: function(){ this.refs.entry.focus() }, _hasCustomValue: function() { if (this.props.allowCustomValues > 0 && this.state.entryValue.length >= this.props.allowCustomValues && this.state.searchResults.indexOf(this.state.entryValue) < 0) { return true; } return false; }, _getCustomValue: function() { if (this._hasCustomValue()) { return this.state.entryValue; } return null; }, _renderIncrementalSearchResults: function() { // Nothing has been entered into the textbox if (this._shouldSkipSearch(this.state.entryValue)) { return ""; } // Something was just selected if (this.state.selection) { return ""; } return ( <this.props.customListComponent ref="sel" options={this.props.maxVisible ? this.state.searchResults.slice(0, this.props.maxVisible) : this.state.searchResults} areResultsTruncated={this.props.maxVisible && this.state.searchResults.length > this.props.maxVisible} resultsTruncatedMessage={this.props.resultsTruncatedMessage} onOptionSelected={this._onOptionSelected} allowCustomValues={this.props.allowCustomValues} customValue={this._getCustomValue()} customClasses={this.props.customClasses} selectionIndex={this.state.selectionIndex} defaultClassNames={this.props.defaultClassNames} displayOption={Accessor.generateOptionToStringFor(this.props.displayOption)} /> ); }, getSelection: function() { var index = this.state.selectionIndex; if (this._hasCustomValue()) { if (index === 0) { return this.state.entryValue; } else { index--; } } return this.state.searchResults[index]; }, _onOptionSelected: function(option, event) { var nEntry = this.refs.entry; nEntry.focus(); var displayOption = Accessor.generateOptionToStringFor(this.props.inputDisplayOption || this.props.displayOption); var optionString = displayOption(option, 0); var formInputOption = Accessor.generateOptionToStringFor(this.props.formInputOption || displayOption); var formInputOptionString = formInputOption(option); nEntry.value = optionString; this.setState({searchResults: this.getOptionsForValue(optionString, this.props.options), selection: formInputOptionString, entryValue: optionString, showResults: false}); return this.props.onOptionSelected(option, event); }, _onTextEntryUpdated: function() { var value = this.refs.entry.value; this.setState({searchResults: this.getOptionsForValue(value, this.props.options), selection: '', entryValue: value}); }, _onEnter: function(event) { var selection = this.getSelection(); if (!selection) { return this.props.onKeyDown(event); } return this._onOptionSelected(selection, event); }, _onEscape: function() { this.setState({ selectionIndex: null }); }, _onTab: function(event) { var selection = this.getSelection(); var option = selection ? selection : (this.state.searchResults.length > 0 ? this.state.searchResults[0] : null); if (option === null && this._hasCustomValue()) { option = this._getCustomValue(); } if (option !== null) { return this._onOptionSelected(option, event); } }, eventMap: function(event) { var events = {}; events[KeyEvent.DOM_VK_UP] = this.navUp; events[KeyEvent.DOM_VK_DOWN] = this.navDown; events[KeyEvent.DOM_VK_RETURN] = events[KeyEvent.DOM_VK_ENTER] = this._onEnter; events[KeyEvent.DOM_VK_ESCAPE] = this._onEscape; events[KeyEvent.DOM_VK_TAB] = this._onTab; return events; }, _nav: function(delta) { if (!this._hasHint()) { return; } var newIndex = this.state.selectionIndex === null ? (delta == 1 ? 0 : delta) : this.state.selectionIndex + delta; var length = this.props.maxVisible ? this.state.searchResults.slice(0, this.props.maxVisible).length : this.state.searchResults.length; if (this._hasCustomValue()) { length += 1; } if (newIndex < 0) { newIndex += length; } else if (newIndex >= length) { newIndex -= length; } this.setState({selectionIndex: newIndex}); }, navDown: function() { this._nav(1); }, navUp: function() { this._nav(-1); }, _onChange: function(event) { if (this.props.onChange) { this.props.onChange(event); } this._onTextEntryUpdated(); }, _onKeyDown: function(event) { // If there are no visible elements, don't perform selector navigation. // Just pass this up to the upstream onKeydown handler. // Also skip if the user is pressing the shift key, since none of our handlers are looking for shift if (!this._hasHint() || event.shiftKey) { return this.props.onKeyDown(event); } var handler = this.eventMap()[event.keyCode]; if (handler) { handler(event); } else { return this.props.onKeyDown(event); } // Don't propagate the keystroke back to the DOM/browser event.preventDefault(); }, componentWillReceiveProps: function(nextProps) { var searchResults = this.getOptionsForValue(this.state.entryValue, nextProps.options); var showResults = Boolean(searchResults.length) && this.state.isFocused; this.setState({ searchResults: searchResults, showResults: showResults }); }, render: function() { var inputClasses = {}; inputClasses[this.props.customClasses.input] = !!this.props.customClasses.input; var inputClassList = classNames(inputClasses); var classes = { typeahead: this.props.defaultClassNames }; classes[this.props.className] = !!this.props.className; var classList = classNames(classes); var InputElement = this.props.textarea ? 'textarea' : 'input'; return ( <div className={classList}> { this._renderHiddenInput() } <InputElement ref="entry" type="text" disabled={this.props.disabled} {...this.props.inputProps} placeholder={this.props.placeholder} className={inputClassList} value={this.state.entryValue} onChange={this._onChange} onKeyDown={this._onKeyDown} onKeyPress={this.props.onKeyPress} onKeyUp={this.props.onKeyUp} onFocus={this._onFocus} onBlur={this._onBlur} /> { this.state.showResults && this._renderIncrementalSearchResults() } </div> ); }, _onFocus: function(event) { this.setState({isFocused: true, showResults: true}, function () { this._onTextEntryUpdated(); }.bind(this)); if ( this.props.onFocus ) { return this.props.onFocus(event); } }, _onBlur: function(event) { this.setState({isFocused: false}, function () { this._onTextEntryUpdated(); }.bind(this)); if ( this.props.onBlur ) { return this.props.onBlur(event); } }, _renderHiddenInput: function() { if (!this.props.name) { return null; } return ( <input type="hidden" name={ this.props.name } value={ this.state.selection } /> ); }, _generateSearchFunction: function() { var searchOptionsProp = this.props.searchOptions; var filterOptionProp = this.props.filterOption; if (typeof searchOptionsProp === 'function') { if (filterOptionProp !== null) { console.warn('searchOptions prop is being used, filterOption prop will be ignored'); } return searchOptionsProp; } else if (typeof filterOptionProp === 'function') { return function(value, options) { return options.filter(function(o) { return filterOptionProp(value, o); }); }; } else { var mapper; if (typeof filterOptionProp === 'string') { mapper = Accessor.generateAccessor(filterOptionProp); } else { mapper = Accessor.IDENTITY_FN; } return function(value, options) { return fuzzy .filter(value, options, {extract: mapper}) .map(function(res) { return options[res.index]; }); }; } }, _hasHint: function() { return this.state.searchResults.length > 0 || this._hasCustomValue(); } }); module.exports = Typeahead;