diff --git a/lib/keyevent.js b/lib/keyevent.js new file mode 100644 index 00000000..9ad60338 --- /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; diff --git a/lib/react-typeahead.js b/lib/react-typeahead.js new file mode 100644 index 00000000..33758468 --- /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 +}; diff --git a/lib/tokenizer/index.js b/lib/tokenizer/index.js new file mode 100644 index 00000000..431a979d --- /dev/null +++ b/lib/tokenizer/index.js @@ -0,0 +1,210 @@ +/** + * @jsx React.DOM + */ + +var 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, + 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 + ]), + displayOption: React.PropTypes.oneOfType([ + React.PropTypes.string, + React.PropTypes.func + ]), + maxVisible: React.PropTypes.number, + defaultClassNames: React.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, + defaultValue: "", + placeholder: "", + disabled: false, + inputProps: {}, + defaultClassNames: true, + filterOption: null, + displayOption: function(token){return token }, + 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 = this.props.displayOption(selected); + return ( + React.createElement(Token, {key: displayString, className: classList, + onRemove: this._removeTokenForValue, + object: selected, + 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(), + defaultValue: this.props.defaultValue, + maxVisible: this.props.maxVisible, + 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}) + ) + ); + } +}); + +module.exports = TypeaheadTokenizer; diff --git a/lib/tokenizer/token.js b/lib/tokenizer/token.js new file mode 100644 index 00000000..8607a097 --- /dev/null +++ b/lib/tokenizer/token.js @@ -0,0 +1,67 @@ +/** + * @jsx React.DOM + */ + +var 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, + object: React.PropTypes.oneOfType([ + React.PropTypes.string, + React.PropTypes.object, + ]), + 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.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)}, "×") + ); + } +}); + +module.exports = Token; diff --git a/lib/typeahead/index.js b/lib/typeahead/index.js new file mode 100644 index 00000000..d8c199cc --- /dev/null +++ b/lib/typeahead/index.js @@ -0,0 +1,395 @@ +/** + * @jsx React.DOM + */ + +var 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, + 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 + ]), + displayOption: 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 + }, + + getDefaultProps: function() { + return { + options: [], + customClasses: {}, + allowCustomValues: 0, + defaultValue: "", + value: null, + 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, + defaultClassNames: true, + customListComponent: TypeaheadSelector, + showOptionsWhenEmpty: false + }; + }, + + 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.value || this.props.defaultValue, + + // A valid typeahead value + selection: this.props.value, + + // Index of the selection + selectionIndex: null + }; + }, + + _shouldSkipSearch: function(input) { + var emptyValue = !input || input.trim().length == 0; + return !this.props.showOptionsWhenEmpty && emptyValue; + }, + + getOptionsForValue: function(value, options) { + if (this._shouldSkipSearch(value)) { return []; } + + 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.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.visible.indexOf(this.state.entryValue) < 0) { + return true; + } + return false; + }, + + _getCustomValue: function() { + if (this._hasCustomValue()) { + return this.state.entryValue; + } + return null; + }, + + _renderIncrementalSearchResults: function() { + // A default value was passed in + if (this.props.defaultValue && !this.state.hasRendered) { + return ""; + } + + // 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.state.visible, + onOptionSelected: this._onOptionSelected, + allowCustomValues: this.props.allowCustomValues, + customValue: this._getCustomValue(), + customClasses: this.props.customClasses, + selectionIndex: this.state.selectionIndex, + defaultClassNames: this.props.defaultClassNames, + 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; + 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.value; + this.setState({visible: this.getOptionsForValue(value, this.props.options), + selection: null, + hasRendered: true, + 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. + // 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({ + 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: 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, React.__spread({ref: "entry", type: "text", + disabled: this.props.disabled}, + this.props.inputProps, + {placeholder: this.props.placeholder, + className: inputClassList, + value: this.state.entryValue, + defaultValue: this.props.defaultValue, + onChange: this._onChange, + onKeyDown: this._onKeyDown, + onKeyPress: this.props.onKeyPress, + 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) { + return fuzzy + .filter(value, options, {extract: mapper}) + .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; diff --git a/lib/typeahead/option.js b/lib/typeahead/option.js new file mode 100644 index 00000000..ced590b7 --- /dev/null +++ b/lib/typeahead/option.js @@ -0,0 +1,65 @@ +/** + * @jsx React.DOM + */ + +var 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; diff --git a/lib/typeahead/selector.js b/lib/typeahead/selector.js new file mode 100644 index 00000000..fae8d55f --- /dev/null +++ b/lib/typeahead/selector.js @@ -0,0 +1,91 @@ +/** + * @jsx React.DOM + */ + +var 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, + 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 + }, + + 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); + + return ( + React.createElement("ul", {className: classList}, + customValue, + results + ) + ); + }, + + _onClick: function(result, event) { + return this.props.onOptionSelected(result, event); + } + +}); + +module.exports = TypeaheadSelector;