UNPKG

react-typeahead

Version:

React-based typeahead and typeahead-tokenizer

966 lines (828 loc) 27.3 kB
!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var f;"undefined"!=typeof window?f=window:"undefined"!=typeof global?f=global:"undefined"!=typeof self&&(f=self),f.ReactTypeahead=e()}}(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){ function classNames() { var classes = ''; var arg; for (var i = 0; i < arguments.length; i++) { arg = arguments[i]; if (!arg) { continue; } if ('string' === typeof arg || 'number' === typeof arg) { classes += ' ' + arg; } else if (Object.prototype.toString.call(arg) === '[object Array]') { classes += ' ' + classNames.apply(null, arg); } else if ('object' === typeof arg) { for (var key in arg) { if (!arg.hasOwnProperty(key) || !arg[key]) { continue; } classes += ' ' + key; } } } return classes.substr(1); } // safely export classNames in case the script is included directly on a page if (typeof module !== 'undefined' && module.exports) { module.exports = classNames; } },{}],2:[function(require,module,exports){ /* * Fuzzy * https://github.com/myork/fuzzy * * Copyright (c) 2012 Matt York * Licensed under the MIT license. */ (function() { var root = this; var fuzzy = {}; // Use in node or in browser if (typeof exports !== 'undefined') { module.exports = fuzzy; } else { root.fuzzy = fuzzy; } // Return all elements of `array` that have a fuzzy // match against `pattern`. fuzzy.simpleFilter = function(pattern, array) { return array.filter(function(string) { return fuzzy.test(pattern, string); }); }; // Does `pattern` fuzzy match `string`? fuzzy.test = function(pattern, string) { return fuzzy.match(pattern, string) !== null; }; // If `pattern` matches `string`, wrap each matching character // in `opts.pre` and `opts.post`. If no match, return null fuzzy.match = function(pattern, string, opts) { opts = opts || {}; var patternIdx = 0 , result = [] , len = string.length , totalScore = 0 , currScore = 0 // prefix , pre = opts.pre || '' // suffix , post = opts.post || '' // String to compare against. This might be a lowercase version of the // raw string , compareString = opts.caseSensitive && string || string.toLowerCase() , ch, compareChar; pattern = opts.caseSensitive && pattern || pattern.toLowerCase(); // For each character in the string, either add it to the result // or wrap in template if its the next string in the pattern for(var idx = 0; idx < len; idx++) { ch = string[idx]; if(compareString[idx] === pattern[patternIdx]) { ch = pre + ch + post; patternIdx += 1; // consecutive characters should increase the score more than linearly currScore += 1 + currScore; } else { currScore = 0; } totalScore += currScore; result[result.length] = ch; } // return rendered string if we have a match for every char if(patternIdx === pattern.length) { return {rendered: result.join(''), score: totalScore}; } return null; }; // The normal entry point. Filters `arr` for matches against `pattern`. // It returns an array with matching values of the type: // // [{ // string: '<b>lah' // The rendered string // , index: 2 // The index of the element in `arr` // , original: 'blah' // The original element in `arr` // }] // // `opts` is an optional argument bag. Details: // // opts = { // // string to put before a matching character // pre: '<b>' // // // string to put after matching character // , post: '</b>' // // // Optional function. Input is an element from the passed in // // `arr`, output should be the string to test `pattern` against. // // In this example, if `arr = [{crying: 'koala'}]` we would return // // 'koala'. // , extract: function(arg) { return arg.crying; } // } fuzzy.filter = function(pattern, arr, opts) { opts = opts || {}; return arr .reduce(function(prev, element, idx, arr) { var str = element; if(opts.extract) { str = opts.extract(element); } var rendered = fuzzy.match(pattern, str, opts); if(rendered != null) { prev[prev.length] = { string: rendered.rendered , score: rendered.score , index: idx , original: element }; } return prev; }, []) // Sort by score. Browsers are inconsistent wrt stable/unstable // sorting, so force stable by using the index in the case of tie. // See http://ofb.net/~sethml/is-sort-stable.html .sort(function(a,b) { var compare = b.score - a.score; if(compare) return compare; return a.index - b.index; }); }; }()); },{}],3:[function(require,module,exports){ /** * PolyFills make me sad */ var KeyEvent = KeyEvent || {}; KeyEvent.DOM_VK_UP = KeyEvent.DOM_VK_UP || 38; KeyEvent.DOM_VK_DOWN = KeyEvent.DOM_VK_DOWN || 40; KeyEvent.DOM_VK_BACK_SPACE = KeyEvent.DOM_VK_BACK_SPACE || 8; KeyEvent.DOM_VK_RETURN = KeyEvent.DOM_VK_RETURN || 13; KeyEvent.DOM_VK_ENTER = KeyEvent.DOM_VK_ENTER || 14; KeyEvent.DOM_VK_ESCAPE = KeyEvent.DOM_VK_ESCAPE || 27; KeyEvent.DOM_VK_TAB = KeyEvent.DOM_VK_TAB || 9; module.exports = KeyEvent; },{}],4:[function(require,module,exports){ var Typeahead = require('./typeahead'); var Tokenizer = require('./tokenizer'); module.exports = { Typeahead: Typeahead, Tokenizer: Tokenizer }; },{"./tokenizer":5,"./typeahead":7}],5:[function(require,module,exports){ /** * @jsx React.DOM */ var React = window.React || require('react'); var Token = require('./token'); var KeyEvent = require('../keyevent'); var Typeahead = require('../typeahead'); var classNames = require('classnames'); function _arraysAreDifferent(array1, array2) { if (array1.length != array2.length){ return true; } for (var i = array2.length - 1; i >= 0; i--) { if (array2[i] !== array1[i]){ return true; } } } /** * A typeahead that, when an option is selected, instead of simply filling * the text entry widget, prepends a renderable "token", that may be deleted * by pressing backspace on the beginning of the line with the keyboard. */ var TypeaheadTokenizer = React.createClass({displayName: "TypeaheadTokenizer", propTypes: { name: React.PropTypes.string, options: React.PropTypes.array, customClasses: React.PropTypes.object, allowCustomValues: React.PropTypes.number, defaultSelected: React.PropTypes.array, defaultValue: React.PropTypes.string, placeholder: React.PropTypes.string, inputProps: React.PropTypes.object, onTokenRemove: React.PropTypes.func, onKeyDown: React.PropTypes.func, onKeyUp: React.PropTypes.func, onTokenAdd: React.PropTypes.func, filterOption: React.PropTypes.func, maxVisible: React.PropTypes.number }, getInitialState: function() { return { // We need to copy this to avoid incorrect sharing // of state across instances (e.g., via getDefaultProps()) selected: this.props.defaultSelected.slice(0) }; }, getDefaultProps: function() { return { options: [], defaultSelected: [], customClasses: {}, allowCustomValues: 0, defaultValue: "", placeholder: "", inputProps: {}, onKeyDown: function(event) {}, onKeyUp: function(event) {}, onTokenAdd: function() {}, onTokenRemove: function() {} }; }, componentWillReceiveProps: function(nextProps){ // if we get new defaultProps, update selected if (_arraysAreDifferent(this.props.defaultSelected, nextProps.defaultSelected)){ this.setState({selected: nextProps.defaultSelected.slice(0)}) } }, focus: function(){ this.refs.typeahead.focus(); }, getSelectedTokens: function(){ return this.state.selected; }, // TODO: Support initialized tokens // _renderTokens: function() { var tokenClasses = {}; tokenClasses[this.props.customClasses.token] = !!this.props.customClasses.token; var classList = classNames(tokenClasses); var result = this.state.selected.map(function(selected) { return ( React.createElement(Token, {key: selected, className: classList, onRemove: this._removeTokenForValue, name: this.props.name}, selected ) ); }, this); return result; }, _getOptionsForTypeahead: function() { // return this.props.options without this.selected return this.props.options; }, _onKeyDown: function(event) { // We only care about intercepting backspaces if (event.keyCode === KeyEvent.DOM_VK_BACK_SPACE) { return this._handleBackspace(event); } this.props.onKeyDown(event); }, _handleBackspace: function(event){ // No tokens if (!this.state.selected.length) { return; } // Remove token ONLY when bksp pressed at beginning of line // without a selection var entry = this.refs.typeahead.refs.entry.getDOMNode(); if (entry.selectionStart == entry.selectionEnd && entry.selectionStart == 0) { this._removeTokenForValue( this.state.selected[this.state.selected.length - 1]); event.preventDefault(); } }, _removeTokenForValue: function(value) { var index = this.state.selected.indexOf(value); if (index == -1) { return; } this.state.selected.splice(index, 1); this.setState({selected: this.state.selected}); this.props.onTokenRemove(value); return; }, _addTokenForValue: function(value) { if (this.state.selected.indexOf(value) != -1) { return; } this.state.selected.push(value); this.setState({selected: this.state.selected}); this.refs.typeahead.setEntryText(""); this.props.onTokenAdd(value); }, render: function() { var classes = {}; classes[this.props.customClasses.typeahead] = !!this.props.customClasses.typeahead; var classList = classNames(classes); return ( React.createElement("div", {className: "typeahead-tokenizer"}, this._renderTokens(), React.createElement(Typeahead, {ref: "typeahead", className: classList, placeholder: this.props.placeholder, inputProps: this.props.inputProps, allowCustomValues: this.props.allowCustomValues, customClasses: this.props.customClasses, options: this._getOptionsForTypeahead(), defaultValue: this.props.defaultValue, maxVisible: this.props.maxVisible, onOptionSelected: this._addTokenForValue, onKeyDown: this._onKeyDown, onKeyUp: this.props.onKeyUp, filterOption: this.props.filterOption}) ) ); } }); module.exports = TypeaheadTokenizer; },{"../keyevent":3,"../typeahead":7,"./token":6,"classnames":1,"react":"react"}],6:[function(require,module,exports){ /** * @jsx React.DOM */ var React = window.React || require('react'); var classNames = require('classnames'); /** * Encapsulates the rendering of an option that has been "selected" in a * TypeaheadTokenizer */ var Token = React.createClass({displayName: "Token", propTypes: { className: React.PropTypes.string, name: React.PropTypes.string, children: React.PropTypes.string, onRemove: React.PropTypes.func }, render: function() { var className = classNames([ "typeahead-token", this.props.className ]); return ( React.createElement("div", {className: className}, this._renderHiddenInput(), this.props.children, this._renderCloseButton() ) ); }, _renderHiddenInput: function() { // If no name was set, don't create a hidden input if (!this.props.name) { return null; } return ( React.createElement("input", { type: "hidden", name: this.props.name + '[]', value: this.props.children} ) ); }, _renderCloseButton: function() { if (!this.props.onRemove) { return ""; } return ( React.createElement("a", {className: "typeahead-token-close", href: "#", onClick: function(event) { this.props.onRemove(this.props.children); event.preventDefault(); }.bind(this)}, "×") ); } }); module.exports = Token; },{"classnames":1,"react":"react"}],7:[function(require,module,exports){ /** * @jsx React.DOM */ var React = window.React || require('react'); var TypeaheadSelector = require('./selector'); var KeyEvent = require('../keyevent'); var fuzzy = require('fuzzy'); var classNames = require('classnames'); var IDENTITY_FN = function(input) { return input; }; var _generateAccessor = function(field) { return function(object) { return object[field]; }; }; /** * 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 = React.createClass({displayName: "Typeahead", propTypes: { name: React.PropTypes.string, customClasses: React.PropTypes.object, maxVisible: React.PropTypes.number, options: React.PropTypes.array, allowCustomValues: React.PropTypes.number, defaultValue: React.PropTypes.string, placeholder: React.PropTypes.string, inputProps: React.PropTypes.object, onOptionSelected: React.PropTypes.func, onChange: React.PropTypes.func, onKeyDown: React.PropTypes.func, onKeyUp: React.PropTypes.func, onFocus: React.PropTypes.func, onBlur: React.PropTypes.func, filterOption: React.PropTypes.oneOfType([ React.PropTypes.string, React.PropTypes.func ]), displayOption: React.PropTypes.oneOfType([ React.PropTypes.string, React.PropTypes.func ]), formInputOption: React.PropTypes.oneOfType([ React.PropTypes.string, React.PropTypes.func ]) }, getDefaultProps: function() { return { options: [], customClasses: {}, allowCustomValues: 0, defaultValue: "", placeholder: "", inputProps: {}, onOptionSelected: function(option) {}, onChange: function(event) {}, onKeyDown: function(event) {}, onKeyUp: function(event) {}, onFocus: function(event) {}, onBlur: function(event) {}, filterOption: null }; }, getInitialState: function() { return { // The currently visible set of options visible: this.getOptionsForValue(this.props.defaultValue, this.props.options), // This should be called something else, "entryValue" entryValue: this.props.defaultValue, // A valid typeahead value selection: null, // Index of the selection selectionIndex: null }; }, getOptionsForValue: function(value, options) { var filterOptions = this._generateFilterFunction(); var result = filterOptions(value, options); if (this.props.maxVisible) { result = result.slice(0, this.props.maxVisible); } return result; }, setEntryText: function(value) { this.refs.entry.getDOMNode().value = value; this._onTextEntryUpdated(); }, focus: function(){ React.findDOMNode(this.refs.entry).focus() }, _hasCustomValue: function() { if (this.props.allowCustomValues > 0 && this.state.entryValue.length >= this.props.allowCustomValues && this.state.visible.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.state.entryValue) { return ""; } // Something was just selected if (this.state.selection) { return ""; } // There are no typeahead / autocomplete suggestions if (!this._hasHint()) { return ""; } return ( React.createElement(TypeaheadSelector, { ref: "sel", options: this.state.visible, onOptionSelected: this._onOptionSelected, customValue: this._getCustomValue(), customClasses: this.props.customClasses, selectionIndex: this.state.selectionIndex, displayOption: this._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.visible[index]; }, _onOptionSelected: function(option, event) { var nEntry = this.refs.entry.getDOMNode(); nEntry.focus(); var displayOption = this._generateOptionToStringFor(this.props.displayOption); var optionString = displayOption(option, 0); var formInputOption = this._generateOptionToStringFor(this.props.formInputOption || displayOption); var formInputOptionString = formInputOption(option); nEntry.value = optionString; this.setState({visible: this.getOptionsForValue(optionString, this.props.options), selection: formInputOptionString, entryValue: optionString}); return this.props.onOptionSelected(option, event); }, _onTextEntryUpdated: function() { var value = this.refs.entry.getDOMNode().value; this.setState({visible: this.getOptionsForValue(value, this.props.options), selection: null, 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.visible.length > 0 ? this.state.visible[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.state.visible.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 if (!this._hasHint()) { 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) { this.setState({ visible: this.getOptionsForValue(this.state.entryValue, nextProps.options) }); }, render: function() { var inputClasses = {}; inputClasses[this.props.customClasses.input] = !!this.props.customClasses.input; var inputClassList = classNames(inputClasses); var classes = { typeahead: true }; classes[this.props.className] = !!this.props.className; var classList = classNames(classes); return ( React.createElement("div", {className: classList}, this._renderHiddenInput(), React.createElement("input", React.__spread({ref: "entry", type: "text"}, this.props.inputProps, {placeholder: this.props.placeholder, className: inputClassList, value: this.state.entryValue, defaultValue: this.props.defaultValue, onChange: this._onChange, onKeyDown: this._onKeyDown, onKeyUp: this.props.onKeyUp, onFocus: this.props.onFocus, onBlur: this.props.onBlur}) ), this._renderIncrementalSearchResults() ) ); }, _renderHiddenInput: function() { if (!this.props.name) { return null; } return ( React.createElement("input", { type: "hidden", name: this.props.name, value: this.state.selection} ) ); }, _generateFilterFunction: function() { var filterOptionProp = this.props.filterOption; 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 = _generateAccessor(filterOptionProp); } else { mapper = IDENTITY_FN; } return function(value, options) { var transformedOptions = options.map(mapper); return fuzzy .filter(value, transformedOptions) .map(function(res) { return options[res.index]; }); }; } }, _generateOptionToStringFor: function(prop) { if (typeof prop === 'string') { return _generateAccessor(prop); } else if (typeof prop === 'function') { return prop; } else { return IDENTITY_FN; } }, _hasHint: function() { return this.state.visible.length > 0 || this._hasCustomValue(); } }); module.exports = Typeahead; },{"../keyevent":3,"./selector":9,"classnames":1,"fuzzy":2,"react":"react"}],8:[function(require,module,exports){ /** * @jsx React.DOM */ var React = window.React || require('react'); var classNames = require('classnames'); /** * A single option within the TypeaheadSelector */ var TypeaheadOption = React.createClass({displayName: "TypeaheadOption", propTypes: { customClasses: React.PropTypes.object, customValue: React.PropTypes.string, onClick: React.PropTypes.func, children: React.PropTypes.string, hover: React.PropTypes.bool }, getDefaultProps: function() { return { customClasses: {}, onClick: function(event) { event.preventDefault(); } }; }, render: function() { var classes = {}; classes[this.props.customClasses.hover || "hover"] = !!this.props.hover; classes[this.props.customClasses.listItem] = !!this.props.customClasses.listItem; if (this.props.customValue) { classes[this.props.customClasses.customAdd] = !!this.props.customClasses.customAdd; } var classList = classNames(classes); return ( React.createElement("li", {className: classList, onClick: this._onClick}, React.createElement("a", {href: "javascript: void 0;", className: this._getClasses(), ref: "anchor"}, this.props.children ) ) ); }, _getClasses: function() { var classes = { "typeahead-option": true, }; classes[this.props.customClasses.listAnchor] = !!this.props.customClasses.listAnchor; return classNames(classes); }, _onClick: function(event) { event.preventDefault(); return this.props.onClick(event); } }); module.exports = TypeaheadOption; },{"classnames":1,"react":"react"}],9:[function(require,module,exports){ /** * @jsx React.DOM */ var React = window.React || require('react'); var TypeaheadOption = require('./option'); var classNames = require('classnames'); /** * Container for the options rendered as part of the autocompletion process * of the typeahead */ var TypeaheadSelector = React.createClass({displayName: "TypeaheadSelector", propTypes: { options: React.PropTypes.array, customClasses: React.PropTypes.object, customValue: React.PropTypes.string, selectionIndex: React.PropTypes.number, onOptionSelected: React.PropTypes.func, displayOption: React.PropTypes.func.isRequired }, getDefaultProps: function() { return { selectionIndex: null, customClasses: {}, customValue: null, onOptionSelected: function(option) { } }; }, render: function() { var classes = { "typeahead-selector": true }; classes[this.props.customClasses.results] = this.props.customClasses.results; var classList = classNames(classes); // CustomValue should be added to top of results list with different class name var customValue = null; var customValueOffset = 0; if (this.props.customValue !== null) { customValueOffset++; customValue = ( React.createElement(TypeaheadOption, {ref: this.props.customValue, key: this.props.customValue, hover: this.props.selectionIndex === 0, customClasses: this.props.customClasses, customValue: this.props.customValue, onClick: this._onClick.bind(this, this.props.customValue)}, this.props.customValue ) ); } var results = this.props.options.map(function(result, i) { var displayString = this.props.displayOption(result, i); return ( React.createElement(TypeaheadOption, {ref: displayString, key: displayString, hover: this.props.selectionIndex === i + customValueOffset, customClasses: this.props.customClasses, onClick: this._onClick.bind(this, result)}, displayString ) ); }, this); return ( React.createElement("ul", {className: classList}, customValue, results ) ); }, _onClick: function(result, event) { return this.props.onOptionSelected(result, event); } }); module.exports = TypeaheadSelector; },{"./option":8,"classnames":1,"react":"react"}]},{},[4])(4) });