diff --git a/.gitignore b/.gitignore index edbd66ee..81518f80 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -lib node_modules .*.swp .*.swo diff --git a/dist/react-typeahead.js b/dist/react-typeahead.js index 9d418a68..cbd23f48 100644 --- a/dist/react-typeahead.js +++ b/dist/react-typeahead.js @@ -268,29 +268,29 @@ 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, - initialValue: React.PropTypes.string, - placeholder: React.PropTypes.string, - disabled: React.PropTypes.bool, - inputProps: React.PropTypes.object, - onTokenRemove: React.PropTypes.func, - onKeyDown: React.PropTypes.func, - onKeyPress: React.PropTypes.func, - onKeyUp: React.PropTypes.func, - onTokenAdd: React.PropTypes.func, - onFocus: React.PropTypes.func, - onBlur: React.PropTypes.func, - filterOption: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.func]), - searchOptions: React.PropTypes.func, - displayOption: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.func]), - formInputOption: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.func]), - maxVisible: React.PropTypes.number, - resultsTruncatedMessage: React.PropTypes.string, - defaultClassNames: React.PropTypes.bool + name: PropTypes.string, + options: PropTypes.array, + customClasses: PropTypes.object, + allowCustomValues: PropTypes.number, + defaultSelected: PropTypes.array, + initialValue: PropTypes.string, + placeholder: PropTypes.string, + disabled: PropTypes.bool, + inputProps: PropTypes.object, + onTokenRemove: PropTypes.func, + onKeyDown: PropTypes.func, + onKeyPress: PropTypes.func, + onKeyUp: PropTypes.func, + onTokenAdd: 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]), + formInputOption: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), + maxVisible: PropTypes.number, + resultsTruncatedMessage: PropTypes.string, + defaultClassNames: PropTypes.bool }, getInitialState: function () { @@ -466,12 +466,12 @@ var Token = React.createClass({ displayName: 'Token', propTypes: { - className: React.PropTypes.string, - name: React.PropTypes.string, - children: React.PropTypes.string, - object: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.object]), - onRemove: React.PropTypes.func, - value: React.PropTypes.string + className: PropTypes.string, + name: PropTypes.string, + children: PropTypes.string, + object: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + onRemove: PropTypes.func, + value: PropTypes.string }, render: function () { @@ -536,33 +536,33 @@ var Typeahead = React.createClass({ displayName: 'Typeahead', propTypes: { - name: React.PropTypes.string, - customClasses: React.PropTypes.object, - maxVisible: React.PropTypes.number, - resultsTruncatedMessage: React.PropTypes.string, - options: React.PropTypes.array, - allowCustomValues: React.PropTypes.number, - initialValue: React.PropTypes.string, - value: React.PropTypes.string, - placeholder: React.PropTypes.string, - disabled: React.PropTypes.bool, - textarea: React.PropTypes.bool, - inputProps: React.PropTypes.object, - onOptionSelected: React.PropTypes.func, - onChange: React.PropTypes.func, - onKeyDown: React.PropTypes.func, - onKeyPress: 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]), - searchOptions: React.PropTypes.func, - displayOption: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.func]), - inputDisplayOption: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.func]), - formInputOption: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.func]), - defaultClassNames: React.PropTypes.bool, - customListComponent: React.PropTypes.oneOfType([React.PropTypes.element, React.PropTypes.func]), - showOptionsWhenEmpty: React.PropTypes.bool + 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 () { @@ -609,7 +609,10 @@ var Typeahead = React.createClass({ // Keep track of the focus state of the input element, to determine // whether to show options when empty (if showOptionsWhenEmpty is true) - isFocused: false + isFocused: false, + + // true when focused, false onOptionSelected + showResults: false }; }, @@ -628,7 +631,7 @@ var Typeahead = React.createClass({ } var searchOptions = this._generateSearchFunction(); - return result = searchOptions(value, options); + return searchOptions(value, options); }, setEntryText: function (value) { @@ -703,7 +706,8 @@ var Typeahead = React.createClass({ nEntry.value = optionString; this.setState({ searchResults: this.getOptionsForValue(optionString, this.props.options), selection: formInputOptionString, - entryValue: optionString }); + entryValue: optionString, + showResults: false }); return this.props.onOptionSelected(option, event); }, @@ -842,12 +846,12 @@ var Typeahead = React.createClass({ onFocus: this._onFocus, onBlur: this._onBlur })), - this._renderIncrementalSearchResults() + this.state.showResults && this._renderIncrementalSearchResults() ); }, _onFocus: function (event) { - this.setState({ isFocused: true }, function () { + this.setState({ isFocused: true, showResults: true }, function () { this._onTextEntryUpdated(); }.bind(this)); if (this.props.onFocus) { @@ -923,11 +927,11 @@ 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 + customClasses: PropTypes.object, + customValue: PropTypes.string, + onClick: PropTypes.func, + children: PropTypes.string, + hover: PropTypes.bool }, getDefaultProps: function () { @@ -991,16 +995,16 @@ var TypeaheadSelector = React.createClass({ displayName: 'TypeaheadSelector', propTypes: { - options: React.PropTypes.array, - allowCustomValues: React.PropTypes.number, - customClasses: React.PropTypes.object, - customValue: React.PropTypes.string, - selectionIndex: React.PropTypes.number, - onOptionSelected: React.PropTypes.func, - displayOption: React.PropTypes.func.isRequired, - defaultClassNames: React.PropTypes.bool, - areResultsTruncated: React.PropTypes.bool, - resultsTruncatedMessage: React.PropTypes.string + options: PropTypes.array, + allowCustomValues: PropTypes.number, + customClasses: PropTypes.object, + customValue: PropTypes.string, + selectionIndex: PropTypes.number, + onOptionSelected: PropTypes.func, + displayOption: PropTypes.func.isRequired, + defaultClassNames: PropTypes.bool, + areResultsTruncated: PropTypes.bool, + resultsTruncatedMessage: PropTypes.string }, getDefaultProps: function () { diff --git a/lib/accessor.js b/lib/accessor.js new file mode 100644 index 00000000..b6e827f1 --- /dev/null +++ b/lib/accessor.js @@ -0,0 +1,33 @@ +var Accessor = { + IDENTITY_FN: function (input) { + return input; + }, + + generateAccessor: function (field) { + return function (object) { + return object[field]; + }; + }, + + generateOptionToStringFor: function (prop) { + if (typeof prop === 'string') { + return this.generateAccessor(prop); + } else if (typeof prop === 'function') { + return prop; + } else { + return this.IDENTITY_FN; + } + }, + + valueForOption: function (option, object) { + if (typeof option === 'string') { + return object[option]; + } else if (typeof option === 'function') { + return option(object); + } else { + return object; + } + } +}; + +module.exports = Accessor; \ No newline at end of file diff --git a/lib/keyevent.js b/lib/keyevent.js new file mode 100644 index 00000000..7c6c3d65 --- /dev/null +++ b/lib/keyevent.js @@ -0,0 +1,13 @@ +/** + * 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; \ No newline at end of file diff --git a/lib/react-typeahead.js b/lib/react-typeahead.js new file mode 100644 index 00000000..f9611ac6 --- /dev/null +++ b/lib/react-typeahead.js @@ -0,0 +1,7 @@ +var Typeahead = require('./typeahead'); +var Tokenizer = require('./tokenizer'); + +module.exports = { + Typeahead: Typeahead, + Tokenizer: Tokenizer +}; \ No newline at end of file diff --git a/lib/tokenizer/index.js b/lib/tokenizer/index.js new file mode 100644 index 00000000..45cd7839 --- /dev/null +++ b/lib/tokenizer/index.js @@ -0,0 +1,214 @@ +var Accessor = require('../accessor'); +var React = require('react'); +var Token = require('./token'); +var KeyEvent = require('../keyevent'); +var Typeahead = require('../typeahead'); +var classNames = require('classnames'); +var createReactClass = require('create-react-class'); +var PropTypes = require('prop-types'); + +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 = createReactClass({ + displayName: 'TypeaheadTokenizer', + + propTypes: { + name: PropTypes.string, + options: PropTypes.array, + customClasses: PropTypes.object, + allowCustomValues: PropTypes.number, + defaultSelected: PropTypes.array, + initialValue: PropTypes.string, + placeholder: PropTypes.string, + disabled: PropTypes.bool, + inputProps: PropTypes.object, + onTokenRemove: PropTypes.func, + onKeyDown: PropTypes.func, + onKeyPress: PropTypes.func, + onKeyUp: PropTypes.func, + onTokenAdd: 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]), + formInputOption: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), + maxVisible: PropTypes.number, + resultsTruncatedMessage: PropTypes.string, + defaultClassNames: PropTypes.bool + }, + + 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, + initialValue: "", + placeholder: "", + disabled: false, + inputProps: {}, + defaultClassNames: true, + filterOption: null, + searchOptions: null, + displayOption: function (token) { + return token; + }, + formInputOption: null, + onKeyDown: function (event) {}, + onKeyPress: function (event) {}, + onKeyUp: function (event) {}, + onFocus: function (event) {}, + onBlur: 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) { + var displayString = Accessor.valueForOption(this.props.displayOption, selected); + var value = Accessor.valueForOption(this.props.formInputOption || this.props.displayOption, selected); + return React.createElement( + Token, + { key: displayString, className: classList, + onRemove: this._removeTokenForValue, + object: selected, + value: value, + name: this.props.name }, + displayString + ); + }, 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; + 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); + var tokenizerClasses = [this.props.defaultClassNames && "typeahead-tokenizer"]; + tokenizerClasses[this.props.className] = !!this.props.className; + var tokenizerClassList = classNames(tokenizerClasses); + + return React.createElement( + 'div', + { className: tokenizerClassList }, + this._renderTokens(), + React.createElement(Typeahead, { ref: 'typeahead', + className: classList, + placeholder: this.props.placeholder, + disabled: this.props.disabled, + inputProps: this.props.inputProps, + allowCustomValues: this.props.allowCustomValues, + customClasses: this.props.customClasses, + options: this._getOptionsForTypeahead(), + initialValue: this.props.initialValue, + maxVisible: this.props.maxVisible, + resultsTruncatedMessage: this.props.resultsTruncatedMessage, + onOptionSelected: this._addTokenForValue, + onKeyDown: this._onKeyDown, + onKeyPress: this.props.onKeyPress, + onKeyUp: this.props.onKeyUp, + onFocus: this.props.onFocus, + onBlur: this.props.onBlur, + displayOption: this.props.displayOption, + defaultClassNames: this.props.defaultClassNames, + filterOption: this.props.filterOption, + searchOptions: this.props.searchOptions }) + ); + } +}); + +module.exports = TypeaheadTokenizer; \ No newline at end of file diff --git a/lib/tokenizer/token.js b/lib/tokenizer/token.js new file mode 100644 index 00000000..fe963095 --- /dev/null +++ b/lib/tokenizer/token.js @@ -0,0 +1,62 @@ +var React = require('react'); +var classNames = require('classnames'); +var createReactClass = require('create-react-class'); +var PropTypes = require('prop-types'); + +/** + * Encapsulates the rendering of an option that has been "selected" in a + * TypeaheadTokenizer + */ +var Token = createReactClass({ + displayName: 'Token', + + propTypes: { + className: PropTypes.string, + name: PropTypes.string, + children: PropTypes.string, + object: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + onRemove: PropTypes.func, + value: PropTypes.string + }, + + 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.value || this.props.object + }); + }, + + _renderCloseButton: function () { + if (!this.props.onRemove) { + return ""; + } + return React.createElement( + 'a', + { className: 'typeahead-token-close', href: '#', onClick: function (event) { + this.props.onRemove(this.props.object); + event.preventDefault(); + }.bind(this) }, + '\xD7' + ); + } +}); + +module.exports = Token; \ No newline at end of file diff --git a/lib/typeahead/index.js b/lib/typeahead/index.js new file mode 100644 index 00000000..4a049123 --- /dev/null +++ b/lib/typeahead/index.js @@ -0,0 +1,400 @@ +var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; + +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({ + displayName: 'Typeahead', + + 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 React.createElement(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) { + this.setState({ + searchResults: 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: this.props.defaultClassNames + }; + classes[this.props.className] = !!this.props.className; + var classList = classNames(classes); + + var InputElement = this.props.textarea ? 'textarea' : 'input'; + return React.createElement( + 'div', + { className: classList }, + this._renderHiddenInput(), + React.createElement(InputElement, _extends({ 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() + ); + }, + + _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 React.createElement('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; \ No newline at end of file diff --git a/lib/typeahead/option.js b/lib/typeahead/option.js new file mode 100644 index 00000000..8a5937cf --- /dev/null +++ b/lib/typeahead/option.js @@ -0,0 +1,66 @@ +var React = require('react'); +var classNames = require('classnames'); +var createReactClass = require('create-react-class'); +var PropTypes = require('prop-types'); + +/** + * A single option within the TypeaheadSelector + */ +var TypeaheadOption = createReactClass({ + displayName: 'TypeaheadOption', + + propTypes: { + customClasses: PropTypes.object, + customValue: PropTypes.string, + onClick: PropTypes.func, + children: PropTypes.string, + hover: 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; \ No newline at end of file diff --git a/lib/typeahead/selector.js b/lib/typeahead/selector.js new file mode 100644 index 00000000..ebc8ca22 --- /dev/null +++ b/lib/typeahead/selector.js @@ -0,0 +1,107 @@ +var React = require('react'); +var TypeaheadOption = require('./option'); +var classNames = require('classnames'); +var createReactClass = require('create-react-class'); +var PropTypes = require('prop-types'); + +/** + * Container for the options rendered as part of the autocompletion process + * of the typeahead + */ +var TypeaheadSelector = createReactClass({ + displayName: 'TypeaheadSelector', + + propTypes: { + options: PropTypes.array, + allowCustomValues: PropTypes.number, + customClasses: PropTypes.object, + customValue: PropTypes.string, + selectionIndex: PropTypes.number, + onOptionSelected: PropTypes.func, + displayOption: PropTypes.func.isRequired, + defaultClassNames: PropTypes.bool, + areResultsTruncated: PropTypes.bool, + resultsTruncatedMessage: PropTypes.string + }, + + getDefaultProps: function () { + return { + selectionIndex: null, + customClasses: {}, + allowCustomValues: 0, + customValue: null, + onOptionSelected: function (option) {}, + defaultClassNames: true + }; + }, + + render: function () { + // Don't render if there are no options to display + if (!this.props.options.length && this.props.allowCustomValues <= 0) { + return false; + } + + var classes = { + "typeahead-selector": this.props.defaultClassNames + }; + 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); + var uniqueKey = displayString + '_' + i; + return React.createElement( + TypeaheadOption, + { ref: uniqueKey, key: uniqueKey, + hover: this.props.selectionIndex === i + customValueOffset, + customClasses: this.props.customClasses, + onClick: this._onClick.bind(this, result) }, + displayString + ); + }, this); + + if (this.props.areResultsTruncated && this.props.resultsTruncatedMessage !== null) { + var resultsTruncatedClasses = { + "results-truncated": this.props.defaultClassNames + }; + resultsTruncatedClasses[this.props.customClasses.resultsTruncated] = this.props.customClasses.resultsTruncated; + var resultsTruncatedClassList = classNames(resultsTruncatedClasses); + + results.push(React.createElement( + 'li', + { key: 'results-truncated', className: resultsTruncatedClassList }, + this.props.resultsTruncatedMessage + )); + } + + return React.createElement( + 'ul', + { className: classList }, + customValue, + results + ); + }, + + _onClick: function (result, event) { + return this.props.onOptionSelected(result, event); + } + +}); + +module.exports = TypeaheadSelector; \ No newline at end of file diff --git a/package.json b/package.json index 196ee7e9..c5a245ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-typeahead", - "version": "2.0.0-alpha.4", + "version": "2.0.0-alpha.5", "description": "React-based typeahead and typeahead-tokenizer", "keywords": [ "react", @@ -33,7 +33,9 @@ }, "dependencies": { "classnames": "^1.2.0", - "fuzzy": "^0.1.0" + "create-react-class": "^15.6.2", + "fuzzy": "^0.1.0", + "prop-types": "^15.6.0" }, "peerDependencies": { "react": ">= 0.14" @@ -41,8 +43,8 @@ "main": "lib/react-typeahead.js", "devDependencies": { "babel-preset-react": "^6.5.0", - "babelify": "^7.2.0", - "browserify": "^8.0.2", + "babelify": "^7.3.0", + "browserify": "^8.1.3", "chai": "^1.9.1", "es5-shim": "^4.0.1", "gulp": "^3.8.7", diff --git a/src/tokenizer/index.js b/src/tokenizer/index.js index 5b10f2e3..0772c12d 100644 --- a/src/tokenizer/index.js +++ b/src/tokenizer/index.js @@ -4,6 +4,8 @@ var Token = require('./token'); var KeyEvent = require('../keyevent'); var Typeahead = require('../typeahead'); var classNames = require('classnames'); +var createReactClass = require('create-react-class'); +var PropTypes = require('prop-types'); function _arraysAreDifferent(array1, array2) { if (array1.length != array2.length){ @@ -21,40 +23,40 @@ function _arraysAreDifferent(array1, array2) { * 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({ +var TypeaheadTokenizer = createReactClass({ propTypes: { - name: React.PropTypes.string, - options: React.PropTypes.array, - customClasses: React.PropTypes.object, - allowCustomValues: React.PropTypes.number, - defaultSelected: React.PropTypes.array, - initialValue: React.PropTypes.string, - placeholder: React.PropTypes.string, - disabled: React.PropTypes.bool, - inputProps: React.PropTypes.object, - onTokenRemove: React.PropTypes.func, - onKeyDown: React.PropTypes.func, - onKeyPress: React.PropTypes.func, - onKeyUp: React.PropTypes.func, - onTokenAdd: React.PropTypes.func, - onFocus: React.PropTypes.func, - onBlur: React.PropTypes.func, - filterOption: React.PropTypes.oneOfType([ - React.PropTypes.string, - React.PropTypes.func + name: PropTypes.string, + options: PropTypes.array, + customClasses: PropTypes.object, + allowCustomValues: PropTypes.number, + defaultSelected: PropTypes.array, + initialValue: PropTypes.string, + placeholder: PropTypes.string, + disabled: PropTypes.bool, + inputProps: PropTypes.object, + onTokenRemove: PropTypes.func, + onKeyDown: PropTypes.func, + onKeyPress: PropTypes.func, + onKeyUp: PropTypes.func, + onTokenAdd: PropTypes.func, + onFocus: PropTypes.func, + onBlur: PropTypes.func, + filterOption: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.func ]), - searchOptions: React.PropTypes.func, - displayOption: React.PropTypes.oneOfType([ - React.PropTypes.string, - React.PropTypes.func + searchOptions: PropTypes.func, + displayOption: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.func ]), - formInputOption: React.PropTypes.oneOfType([ - React.PropTypes.string, - React.PropTypes.func + formInputOption: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.func ]), - maxVisible: React.PropTypes.number, - resultsTruncatedMessage: React.PropTypes.string, - defaultClassNames: React.PropTypes.bool + maxVisible: PropTypes.number, + resultsTruncatedMessage: PropTypes.string, + defaultClassNames: PropTypes.bool }, getInitialState: function() { diff --git a/src/tokenizer/token.js b/src/tokenizer/token.js index 8f903244..ee2a170f 100644 --- a/src/tokenizer/token.js +++ b/src/tokenizer/token.js @@ -1,21 +1,23 @@ var React = require('react'); var classNames = require('classnames'); +var createReactClass = require('create-react-class'); +var PropTypes = require('prop-types'); /** * Encapsulates the rendering of an option that has been "selected" in a * TypeaheadTokenizer */ -var Token = React.createClass({ +var Token = createReactClass({ propTypes: { - className: React.PropTypes.string, - name: React.PropTypes.string, - children: React.PropTypes.string, - object: React.PropTypes.oneOfType([ - React.PropTypes.string, - React.PropTypes.object, + className: PropTypes.string, + name: PropTypes.string, + children: PropTypes.string, + object: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object, ]), - onRemove: React.PropTypes.func, - value: React.PropTypes.string + onRemove: PropTypes.func, + value: PropTypes.string }, render: function() { diff --git a/src/typeahead/index.js b/src/typeahead/index.js index 5a2b72bc..8605a2fa 100644 --- a/src/typeahead/index.js +++ b/src/typeahead/index.js @@ -4,6 +4,8 @@ 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 @@ -11,50 +13,50 @@ var classNames = require('classnames'); * 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({ +var Typeahead = createReactClass({ propTypes: { - name: React.PropTypes.string, - customClasses: React.PropTypes.object, - maxVisible: React.PropTypes.number, - resultsTruncatedMessage: React.PropTypes.string, - options: React.PropTypes.array, - allowCustomValues: React.PropTypes.number, - initialValue: React.PropTypes.string, - value: React.PropTypes.string, - placeholder: React.PropTypes.string, - disabled: React.PropTypes.bool, - textarea: React.PropTypes.bool, - inputProps: React.PropTypes.object, - onOptionSelected: React.PropTypes.func, - onChange: React.PropTypes.func, - onKeyDown: React.PropTypes.func, - onKeyPress: 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 + 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: React.PropTypes.func, - displayOption: React.PropTypes.oneOfType([ - React.PropTypes.string, - React.PropTypes.func + searchOptions: PropTypes.func, + displayOption: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.func ]), - inputDisplayOption: React.PropTypes.oneOfType([ - React.PropTypes.string, - React.PropTypes.func + inputDisplayOption: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.func ]), - formInputOption: React.PropTypes.oneOfType([ - React.PropTypes.string, - React.PropTypes.func + formInputOption: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.func ]), - defaultClassNames: React.PropTypes.bool, - customListComponent: React.PropTypes.oneOfType([ - React.PropTypes.element, - React.PropTypes.func + defaultClassNames: PropTypes.bool, + customListComponent: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.func ]), - showOptionsWhenEmpty: React.PropTypes.bool + showOptionsWhenEmpty: PropTypes.bool }, getDefaultProps: function() { diff --git a/src/typeahead/option.js b/src/typeahead/option.js index b6dfc313..d878e3a6 100644 --- a/src/typeahead/option.js +++ b/src/typeahead/option.js @@ -1,16 +1,18 @@ var React = require('react'); var classNames = require('classnames'); +var createReactClass = require('create-react-class'); +var PropTypes = require('prop-types'); /** * A single option within the TypeaheadSelector */ -var TypeaheadOption = React.createClass({ +var TypeaheadOption = createReactClass({ propTypes: { - customClasses: React.PropTypes.object, - customValue: React.PropTypes.string, - onClick: React.PropTypes.func, - children: React.PropTypes.string, - hover: React.PropTypes.bool + customClasses: PropTypes.object, + customValue: PropTypes.string, + onClick: PropTypes.func, + children: PropTypes.string, + hover: PropTypes.bool }, getDefaultProps: function() { diff --git a/src/typeahead/selector.js b/src/typeahead/selector.js index ac449161..c3ec5402 100644 --- a/src/typeahead/selector.js +++ b/src/typeahead/selector.js @@ -1,23 +1,25 @@ var React = require('react'); var TypeaheadOption = require('./option'); var classNames = require('classnames'); +var createReactClass = require('create-react-class'); +var PropTypes = require('prop-types'); /** * Container for the options rendered as part of the autocompletion process * of the typeahead */ -var TypeaheadSelector = React.createClass({ +var TypeaheadSelector = createReactClass({ propTypes: { - options: React.PropTypes.array, - allowCustomValues: React.PropTypes.number, - customClasses: React.PropTypes.object, - customValue: React.PropTypes.string, - selectionIndex: React.PropTypes.number, - onOptionSelected: React.PropTypes.func, - displayOption: React.PropTypes.func.isRequired, - defaultClassNames: React.PropTypes.bool, - areResultsTruncated: React.PropTypes.bool, - resultsTruncatedMessage: React.PropTypes.string + options: PropTypes.array, + allowCustomValues: PropTypes.number, + customClasses: PropTypes.object, + customValue: PropTypes.string, + selectionIndex: PropTypes.number, + onOptionSelected: PropTypes.func, + displayOption: PropTypes.func.isRequired, + defaultClassNames: PropTypes.bool, + areResultsTruncated: PropTypes.bool, + resultsTruncatedMessage: PropTypes.string }, getDefaultProps: function() { diff --git a/test/typeahead-test.js b/test/typeahead-test.js index b01bacec..06d490c3 100644 --- a/test/typeahead-test.js +++ b/test/typeahead-test.js @@ -583,7 +583,7 @@ describe('Typeahead Component', function() { context('customListComponent', function() { before(function() { - ListComponent = React.createClass({ + ListComponent = createReactClass({ render: function() { return
; }