From 6b309136de185b577cb9adf960bb5dc557bf9316 Mon Sep 17 00:00:00 2001 From: Edd Morgan Date: Tue, 2 May 2017 13:54:37 +0100 Subject: [PATCH 1/4] whitelist props that get passed to option div --- src/option.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/option.js b/src/option.js index a3ab64b..c3048f0 100644 --- a/src/option.js +++ b/src/option.js @@ -40,7 +40,11 @@ module.exports = React.createClass({ props.className = addClass(props.className, 'ic-tokeninput-selected'); props.ariaSelected = true; } - return div(props); + return div({ + role: props.role, + tabIndex: props.tabIndex, + className: props.className + }); } }); From a33a4dea6ffda324ee568ce762bfc68c722a6b0c Mon Sep 17 00:00:00 2001 From: Edd Morgan Date: Tue, 2 May 2017 14:09:12 +0100 Subject: [PATCH 2/4] don't ignore lib for now --- .gitignore | 2 +- lib/add-class.js | 9 + lib/combobox.js | 451 +++++++++++++++++++++++++++++++++++++++++++++++ lib/index.js | 38 ++++ lib/main.js | 98 ++++++++++ lib/option.js | 54 ++++++ lib/token.js | 36 ++++ 7 files changed, 687 insertions(+), 1 deletion(-) create mode 100644 lib/add-class.js create mode 100644 lib/combobox.js create mode 100644 lib/index.js create mode 100644 lib/main.js create mode 100644 lib/option.js create mode 100644 lib/token.js diff --git a/.gitignore b/.gitignore index f339102..beb4f27 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,6 @@ TODO *.log node_modules tmp -lib +!lib/ example/bundle.js yarn.lock diff --git a/lib/add-class.js b/lib/add-class.js new file mode 100644 index 0000000..70959d1 --- /dev/null +++ b/lib/add-class.js @@ -0,0 +1,9 @@ +'use strict'; + +module.exports = addClass; + +function addClass(existing, added) { + if (!existing) return added; + if (existing.indexOf(added) > -1) return existing; + return existing + ' ' + added; +} \ No newline at end of file diff --git a/lib/combobox.js b/lib/combobox.js new file mode 100644 index 0000000..0ed32ac --- /dev/null +++ b/lib/combobox.js @@ -0,0 +1,451 @@ +'use strict'; + +var React = require('react'); +var guid = 0; +var k = function k() {}; +var addClass = require('./add-class'); +var ComboboxOption = require('./option'); + +var div = React.createFactory('div'); +var span = React.createFactory('span'); +var input = React.createFactory('input'); + +module.exports = React.createClass({ + displayName: 'exports', + + + propTypes: { + onFocus: React.PropTypes.func, + + /** + * Called when the combobox receives user input, this is your chance to + * filter the data and rerender the options. + * + * Signature: + * + * ```js + * function(userInput){} + * ``` + */ + onInput: React.PropTypes.func, + + /** + * Called when the combobox receives a selection. You probably want to reset + * the options to the full list at this point. + * + * Signature: + * + * ```js + * function(selectedValue){} + * ``` + */ + onSelect: React.PropTypes.func, + + /** + * Shown when the combobox is empty. + */ + placeholder: React.PropTypes.string + }, + + getDefaultProps: function getDefaultProps() { + return { + autocomplete: 'both', + onFocus: k, + onInput: k, + onSelect: k, + value: null, + showListOnFocus: false + }; + }, + + getInitialState: function getInitialState() { + return { + value: this.props.value, + // the value displayed in the input + inputValue: this.findInitialInputValue(), + isOpen: false, + focusedIndex: null, + matchedAutocompleteOption: null, + // this prevents crazy jumpiness since we focus options on mouseenter + usingKeyboard: false, + activedescendant: null, + listId: 'ic-tokeninput-list-' + ++guid, + menu: { + children: [], + activedescendant: null, + isEmpty: true + } + }; + }, + + componentWillMount: function componentWillMount() { + this.setState({ menu: this.makeMenu(this.props.children) }); + }, + + componentWillReceiveProps: function componentWillReceiveProps(newProps) { + this.setState({ menu: this.makeMenu(newProps.children) }, function () { + if (newProps.children.length && (this.isOpen || document.activeElement === this.refs.input)) { + if (!this.state.menu.children.length) { + return; + } + this.setState({ + isOpen: true + }, function () { + this.refs.list.scrollTop = 0; + }.bind(this)); + } else { + this.hideList(); + } + }.bind(this)); + }, + + /** + * We don't create the components, the user supplies them, + * so before rendering we attach handlers to facilitate communication from + * the ComboboxOption to the Combobox. + */ + makeMenu: function makeMenu(children) { + var activedescendant; + var isEmpty = true; + + // Should this instead use React.addons.cloneWithProps or React.cloneElement? + var _children = React.Children.map(children, function (child, index) { + // console.log(child.type, ComboboxOption.type) + if (child.type !== ComboboxOption || !child.props.isFocusable) { + // allow random elements to live in this list + return child; + } + isEmpty = false; + // TODO: cloneWithProps and map instead of altering the children in-place + var props = child.props; + var newProps = {}; + if (this.state.value === child.props.value) { + // need an ID for WAI-ARIA + newProps.id = props.id || 'ic-tokeninput-selected-' + ++guid; + newProps.isSelected = true; + activedescendant = props.id; + } + newProps.onBlur = this.handleOptionBlur; + newProps.onClick = this.selectOption.bind(this, child); + newProps.onFocus = this.handleOptionFocus; + newProps.onKeyDown = this.handleOptionKeyDown.bind(this, child); + newProps.onMouseEnter = this.handleOptionMouseEnter.bind(this, index); + + return React.cloneElement(child, newProps); + }.bind(this)); + + return { + children: _children, + activedescendant: activedescendant, + isEmpty: isEmpty + }; + }, + + getClassName: function getClassName() { + var className = addClass(this.props.className, 'ic-tokeninput'); + if (this.state.isOpen) className = addClass(className, 'ic-tokeninput-is-open'); + return className; + }, + + /** + * When the user begins typing again we need to clear out any state that has + * to do with an existing or potential selection. + */ + clearSelectedState: function clearSelectedState(cb) { + this.setState({ + focusedIndex: null, + inputValue: null, + value: null, + matchedAutocompleteOption: null, + activedescendant: null + }, cb); + }, + + handleInputChange: function handleInputChange() { + var value = this.refs.input.value; + this.clearSelectedState(function () { + this.props.onInput(value); + }.bind(this)); + }, + + handleInputFocus: function handleInputFocus() { + this.props.onFocus(); + this.maybeShowList(); + }, + + handleInputClick: function handleInputClick() { + this.maybeShowList(); + }, + + maybeShowList: function maybeShowList() { + if (this.props.showListOnFocus) { + this.showList(); + } + }, + + handleInputBlur: function handleInputBlur() { + var focusedAnOption = this.state.focusedIndex != null; + if (focusedAnOption) return; + this.maybeSelectAutocompletedOption(); + this.hideList(); + }, + + handleOptionBlur: function handleOptionBlur() { + // don't want to hide the list if we focused another option + this.blurTimer = setTimeout(this.hideList, 0); + }, + + handleOptionFocus: function handleOptionFocus() { + // see `handleOptionBlur` + clearTimeout(this.blurTimer); + }, + + handleInputKeyUp: function handleInputKeyUp(event) { + if (this.state.menu.isEmpty || + // autocompleting while backspacing feels super weird, so let's not + event.keyCode === 8 /*backspace*/ || !this.props.autocomplete.match(/both|inline/)) return; + }, + + handleButtonClick: function handleButtonClick() { + this.state.isOpen ? this.hideList() : this.showList(); + this.focusInput(); + }, + + showList: function showList() { + if (!this.state.menu.children.length) { + return; + } + this.setState({ isOpen: true }); + }, + + hideList: function hideList() { + this.setState({ + isOpen: false, + focusedIndex: null + }); + }, + + hideOnEscape: function hideOnEscape(event) { + this.hideList(); + this.focusInput(); + event.preventDefault(); + }, + + focusInput: function focusInput() { + this.refs.input.focus(); + }, + + selectInput: function selectInput() { + this.refs.input.select(); + }, + + inputKeydownMap: { + 8: 'removeLastToken', // delete + 13: 'selectOnEnter', // enter + 188: 'selectOnEnter', // comma + 27: 'hideOnEscape', // escape + 38: 'focusPrevious', // up arrow + 40: 'focusNext' // down arrow + }, + + optionKeydownMap: { + 13: 'selectOption', + 27: 'hideOnEscape', + 38: 'focusPrevious', + 40: 'focusNext' + }, + + handleKeydown: function handleKeydown(event) { + var handlerName = this.inputKeydownMap[event.keyCode]; + if (!handlerName) return; + this.setState({ usingKeyboard: true }); + return this[handlerName].call(this, event); + }, + + handleOptionKeyDown: function handleOptionKeyDown(child, event) { + var handlerName = this.optionKeydownMap[event.keyCode]; + if (!handlerName) { + // if the user starts typing again while focused on an option, move focus + // to the inpute, select so it wipes out any existing value + this.selectInput(); + return; + } + event.preventDefault(); + this.setState({ usingKeyboard: true }); + this[handlerName].call(this, child); + }, + + handleOptionMouseEnter: function handleOptionMouseEnter(index) { + if (this.state.usingKeyboard) this.setState({ usingKeyboard: false });else this.focusOptionAtIndex(index); + }, + + selectOnEnter: function selectOnEnter(event) { + event.preventDefault(); + this.maybeSelectAutocompletedOption(); + }, + + maybeSelectAutocompletedOption: function maybeSelectAutocompletedOption() { + if (!this.state.matchedAutocompleteOption) { + this.selectText(); + } else { + this.selectOption(this.state.matchedAutocompleteOption, { focus: false }); + } + }, + + selectOption: function selectOption(child, options) { + options = options || {}; + this.setState({ + // value: child.props.value, + // inputValue: getLabel(child), + matchedAutocompleteOption: null + }, function () { + this.props.onSelect(child.props.value, child); + this.hideList(); + this.clearSelectedState(); // added + if (options.focus !== false) this.selectInput(); + }.bind(this)); + this.refs.input.value = ''; // added + }, + + selectText: function selectText() { + var value = this.refs.input.value; + if (!value) return; + this.props.onSelect(value); + this.clearSelectedState(); + this.refs.input.value = ''; // added + }, + + focusNext: function focusNext(event) { + if (event.preventDefault) event.preventDefault(); + if (this.state.menu.isEmpty) return; + var index = this.nextFocusableIndex(this.state.focusedIndex); + this.focusOptionAtIndex(index); + }, + + removeLastToken: function removeLastToken() { + if (this.props.onRemoveLast && !this.refs.input.value) { + this.props.onRemoveLast(); + } + return true; + }, + + focusPrevious: function focusPrevious(event) { + if (event.preventDefault) event.preventDefault(); + if (this.state.menu.isEmpty) return; + var index = this.previousFocusableIndex(this.state.focusedIndex); + this.focusOptionAtIndex(index); + }, + + focusSelectedOption: function focusSelectedOption() { + var selectedIndex; + React.Children.forEach(this.props.children, function (child, index) { + if (child.props.value === this.state.value) selectedIndex = index; + }.bind(this)); + this.showList(); + this.setState({ + focusedIndex: selectedIndex + }, this.focusOption); + }, + + findInitialInputValue: function findInitialInputValue() { + // TODO: might not need this, we should know this in `makeMenu` + var inputValue; + React.Children.forEach(this.props.children, function (child) { + if (child.props.value === this.props.value) inputValue = getLabel(child); + }.bind(this)); + return inputValue; + }, + + clampIndex: function clampIndex(index) { + if (index < 0) { + return this.props.children.length - 1; + } else if (index >= this.props.children.length) { + return 0; + } + return index; + }, + + scanForFocusableIndex: function scanForFocusableIndex(index, increment) { + if (index === null || index === undefined) { + index = increment > 0 ? this.clampIndex(-1) : 0; + } + var newIndex = index; + while (true) { + newIndex = this.clampIndex(newIndex + increment); + if (newIndex === index || this.props.children[newIndex].props.isFocusable) { + return newIndex; + } + } + }, + + nextFocusableIndex: function nextFocusableIndex(index) { + return this.scanForFocusableIndex(index, 1); + }, + + previousFocusableIndex: function previousFocusableIndex(index) { + return this.scanForFocusableIndex(index, -1); + }, + + focusOptionAtIndex: function focusOptionAtIndex(index) { + if (!this.state.isOpen && this.state.value) return this.focusSelectedOption(); + this.showList(); + var length = this.props.children.length; + if (index === -1) index = length - 1;else if (index === length) index = 0; + this.setState({ + focusedIndex: index + }, this.focusOption); + }, + + focusOption: function focusOption() { + var index = this.state.focusedIndex; + this.refs.list.childNodes[index].focus(); + }, + + render: function render() { + var ariaLabel = this.props['aria-label'] || 'Start typing to search. ' + 'Press the down arrow to navigate results. If you don\'t find an ' + 'acceptable option, you can input an alternative. Once you find or ' + 'input the tag you want, press Enter or Comma to add it.'; + + return div({ className: this.getClassName() }, this.props.value, this.state.inputValue, input({ + ref: 'input', + autoComplete: 'off', + spellCheck: 'false', + 'aria-label': ariaLabel, + 'aria-expanded': this.state.isOpen + '', + 'aria-haspopup': 'true', + 'aria-activedescendant': this.state.menu.activedescendant, + 'aria-autocomplete': 'list', + 'aria-owns': this.state.listId, + id: this.props.id, + disabled: this.props.isDisabled, + className: 'ic-tokeninput-input', + onFocus: this.handleInputFocus, + onClick: this.handleInputClick, + onChange: this.handleInputChange, + onBlur: this.handleInputBlur, + onKeyDown: this.handleKeydown, + onKeyUp: this.handleInputKeyUp, + placeholder: this.props.placeholder, + role: 'combobox' + }), span({ + 'aria-hidden': 'true', + className: 'ic-tokeninput-button', + onClick: this.handleButtonClick + }, '▾'), div({ + id: this.state.listId, + ref: 'list', + className: 'ic-tokeninput-list', + role: 'listbox' + }, this.state.menu.children)); + } +}); + +function getLabel(component) { + return component.props.label || component.props.children; +} + +function matchFragment(userInput, firstChildLabel) { + userInput = userInput.toLowerCase(); + firstChildLabel = firstChildLabel.toLowerCase(); + if (userInput === '' || userInput === firstChildLabel) return false; + if (firstChildLabel.toLowerCase().indexOf(userInput.toLowerCase()) === -1) return false; + return true; +} \ No newline at end of file diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..beaf88d --- /dev/null +++ b/lib/index.js @@ -0,0 +1,38 @@ +'use strict'; + +Object.defineProperty(exports, "__esModule", { + value: true +}); +exports.Token = exports.Option = exports.Combobox = undefined; + +var _combobox = require('./combobox'); + +var _combobox2 = _interopRequireDefault(_combobox); + +var _option = require('./option'); + +var _option2 = _interopRequireDefault(_option); + +var _token = require('./token'); + +var _token2 = _interopRequireDefault(_token); + +var _main = require('./main'); + +var _main2 = _interopRequireDefault(_main); + +function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } + +exports.Combobox = _combobox2.default; +exports.Option = _option2.default; +exports.Token = _token2.default; + + +/** + * You can't do an import and then immediately export it :( + * And `export default TokenInput from './main'` doesn't seem to + * work either :( + * So this little variable swapping stuff gets it to work. + */ +var TokenInput = _main2.default; +exports.default = TokenInput; \ No newline at end of file diff --git a/lib/main.js b/lib/main.js new file mode 100644 index 0000000..e134022 --- /dev/null +++ b/lib/main.js @@ -0,0 +1,98 @@ +'use strict'; + +var React = require('react'); +var Combobox = React.createFactory(require('./combobox')); +var Token = React.createFactory(require('./token')); +var classnames = require('classnames'); + +var ul = React.DOM.ul; +var li = React.DOM.li; + +module.exports = React.createClass({ + displayName: 'exports', + + propTypes: { + isLoading: React.PropTypes.bool, + loadingComponent: React.PropTypes.any, + onFocus: React.PropTypes.func, + onInput: React.PropTypes.func.isRequired, + onSelect: React.PropTypes.func.isRequired, + tokenAriaFunc: React.PropTypes.func, + onRemove: React.PropTypes.func.isRequired, + selected: React.PropTypes.array.isRequired, + menuContent: React.PropTypes.any, + showListOnFocus: React.PropTypes.bool, + placeholder: React.PropTypes.string + }, + + getInitialState: function getInitialState() { + return { + selectedToken: null + }; + }, + + handleClick: function handleClick() { + // TODO: Expand combobox API for focus + this.refs['combo-li'].querySelector('input').focus(); + }, + + handleFocus: function handleFocus() { + if (this.props.onFocus) { + this.props.onFocus(); + } + }, + + handleInput: function handleInput(inputValue) { + this.props.onInput(inputValue); + }, + + handleSelect: function handleSelect(event, option) { + var input = this.refs['combo-li'].querySelector('input'); + this.props.onSelect(event, option); + this.setState({ + selectedToken: null + }); + this.props.onInput(input.value); + }, + + handleRemove: function handleRemove(value) { + var input = this.refs['combo-li'].querySelector('input'); + this.props.onRemove(value); + input.focus(); + }, + + handleRemoveLast: function handleRemoveLast() { + this.props.onRemove(this.props.selected[this.props.selected.length - 1]); + }, + + render: function render() { + var isDisabled = this.props.isDisabled; + var tokens = this.props.selected.map(function (token) { + return Token({ + tokenAriaFunc: this.props.tokenAriaFunc, + onFocus: this.handleFocus, + onRemove: this.handleRemove, + value: token, + name: token.name, + key: token.id }); + }.bind(this)); + + var classes = classnames('ic-tokens flex', { + 'ic-tokens-disabled': isDisabled + }); + + return ul({ className: classes, onClick: this.handleClick }, tokens, li({ className: 'inline-flex', ref: 'combo-li' }, Combobox({ + id: this.props.id, + 'aria-label': this.props['combobox-aria-label'], + ariaDisabled: isDisabled, + onFocus: this.handleFocus, + onInput: this.handleInput, + showListOnFocus: this.props.showListOnFocus, + onSelect: this.handleSelect, + onRemoveLast: this.handleRemoveLast, + value: this.state.selectedToken, + isDisabled: isDisabled, + placeholder: this.props.placeholder + }, this.props.menuContent)), this.props.isLoading && li({ className: 'ic-tokeninput-loading flex' }, this.props.loadingComponent)); + } +}); \ No newline at end of file diff --git a/lib/option.js b/lib/option.js new file mode 100644 index 0000000..f06e947 --- /dev/null +++ b/lib/option.js @@ -0,0 +1,54 @@ +'use strict'; + +var React = require('react'); +var addClass = require('./add-class'); +var div = React.createFactory('div'); + +module.exports = React.createClass({ + displayName: 'exports', + + + propTypes: { + + /** + * The value that will be sent to the `onSelect` handler of the + * parent Combobox. + */ + value: React.PropTypes.any.isRequired, + + /** + * What value to put into the input element when this option is + * selected, defaults to its children coerced to a string. + */ + label: React.PropTypes.string, + + /** + * Whether the element should be selectable + */ + isFocusable: React.PropTypes.bool + }, + + getDefaultProps: function getDefaultProps() { + return { + role: 'option', + tabIndex: '-1', + className: 'ic-tokeninput-option', + isSelected: false, + isFocusable: true + }; + }, + + render: function render() { + var props = this.props; + if (props.isSelected) { + props.className = addClass(props.className, 'ic-tokeninput-selected'); + props.ariaSelected = true; + } + return div({ + role: props.role, + tabIndex: props.tabIndex, + className: props.className + }); + } + +}); \ No newline at end of file diff --git a/lib/token.js b/lib/token.js new file mode 100644 index 0000000..9e3bcac --- /dev/null +++ b/lib/token.js @@ -0,0 +1,36 @@ +'use strict'; + +var React = require('react'); +var span = React.DOM.span; +var li = React.createFactory('li'); + +module.exports = React.createClass({ + displayName: 'exports', + + handleClick: function handleClick() { + this.props.onRemove(this.props.value); + }, + + handleKeyDown: function handleKeyDown(key) { + var enterKey = 13; + if (key.keyCode === enterKey) this.props.onRemove(this.props.value); + }, + + ariaLabelRemove: function ariaLabelRemove() { + return this.props.tokenAriaFunc ? this.props.tokenAriaFunc(this.props.name) : 'Remove \'' + this.props.name + '\''; + }, + + render: function render() { + return li({ + className: "ic-token inline-flex" + }, span({ className: "ic-token-label" }, this.props.name), span({ + role: 'button', + onClick: this.handleClick, + onFocus: this.props.onFocus, + onKeyDown: this.handleKeyDown, + 'aria-label': this.ariaLabelRemove(), + className: "ic-token-delete-button", + tabIndex: 0 + }, "✕")); + } +}); \ No newline at end of file From aa23ba9372afd249084982ab94985d1f0f48a7a6 Mon Sep 17 00:00:00 2001 From: Edd Morgan Date: Tue, 2 May 2017 14:34:58 +0100 Subject: [PATCH 3/4] dont pass invalid props to option div --- package.json | 3 ++- src/option.js | 7 ++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 4ec8ff4..482ff78 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "webpack-dev-server": "^1.12.0" }, "dependencies": { - "classnames": "^2.2.1" + "classnames": "^2.2.1", + "object.omit": "^2.0.1" } } diff --git a/src/option.js b/src/option.js index c3048f0..24d2782 100644 --- a/src/option.js +++ b/src/option.js @@ -1,5 +1,6 @@ var React = require('react'); var addClass = require('./add-class'); +var omit = require('object.omit'); var div = React.createFactory('div'); module.exports = React.createClass({ @@ -40,11 +41,7 @@ module.exports = React.createClass({ props.className = addClass(props.className, 'ic-tokeninput-selected'); props.ariaSelected = true; } - return div({ - role: props.role, - tabIndex: props.tabIndex, - className: props.className - }); + return div(omit(props, ['isSelected', 'isFocusable'])); } }); From cbe3621849e6cbe7379b6473838fb2232df6eab9 Mon Sep 17 00:00:00 2001 From: Edd Morgan Date: Tue, 2 May 2017 14:40:47 +0100 Subject: [PATCH 4/4] Revert "don't ignore lib for now" This reverts commit a33a4dea6ffda324ee568ce762bfc68c722a6b0c. --- .gitignore | 2 +- lib/add-class.js | 9 - lib/combobox.js | 451 ----------------------------------------------- lib/index.js | 38 ---- lib/main.js | 98 ---------- lib/option.js | 54 ------ lib/token.js | 36 ---- 7 files changed, 1 insertion(+), 687 deletions(-) delete mode 100644 lib/add-class.js delete mode 100644 lib/combobox.js delete mode 100644 lib/index.js delete mode 100644 lib/main.js delete mode 100644 lib/option.js delete mode 100644 lib/token.js diff --git a/.gitignore b/.gitignore index beb4f27..f339102 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,6 @@ TODO *.log node_modules tmp -!lib/ +lib example/bundle.js yarn.lock diff --git a/lib/add-class.js b/lib/add-class.js deleted file mode 100644 index 70959d1..0000000 --- a/lib/add-class.js +++ /dev/null @@ -1,9 +0,0 @@ -'use strict'; - -module.exports = addClass; - -function addClass(existing, added) { - if (!existing) return added; - if (existing.indexOf(added) > -1) return existing; - return existing + ' ' + added; -} \ No newline at end of file diff --git a/lib/combobox.js b/lib/combobox.js deleted file mode 100644 index 0ed32ac..0000000 --- a/lib/combobox.js +++ /dev/null @@ -1,451 +0,0 @@ -'use strict'; - -var React = require('react'); -var guid = 0; -var k = function k() {}; -var addClass = require('./add-class'); -var ComboboxOption = require('./option'); - -var div = React.createFactory('div'); -var span = React.createFactory('span'); -var input = React.createFactory('input'); - -module.exports = React.createClass({ - displayName: 'exports', - - - propTypes: { - onFocus: React.PropTypes.func, - - /** - * Called when the combobox receives user input, this is your chance to - * filter the data and rerender the options. - * - * Signature: - * - * ```js - * function(userInput){} - * ``` - */ - onInput: React.PropTypes.func, - - /** - * Called when the combobox receives a selection. You probably want to reset - * the options to the full list at this point. - * - * Signature: - * - * ```js - * function(selectedValue){} - * ``` - */ - onSelect: React.PropTypes.func, - - /** - * Shown when the combobox is empty. - */ - placeholder: React.PropTypes.string - }, - - getDefaultProps: function getDefaultProps() { - return { - autocomplete: 'both', - onFocus: k, - onInput: k, - onSelect: k, - value: null, - showListOnFocus: false - }; - }, - - getInitialState: function getInitialState() { - return { - value: this.props.value, - // the value displayed in the input - inputValue: this.findInitialInputValue(), - isOpen: false, - focusedIndex: null, - matchedAutocompleteOption: null, - // this prevents crazy jumpiness since we focus options on mouseenter - usingKeyboard: false, - activedescendant: null, - listId: 'ic-tokeninput-list-' + ++guid, - menu: { - children: [], - activedescendant: null, - isEmpty: true - } - }; - }, - - componentWillMount: function componentWillMount() { - this.setState({ menu: this.makeMenu(this.props.children) }); - }, - - componentWillReceiveProps: function componentWillReceiveProps(newProps) { - this.setState({ menu: this.makeMenu(newProps.children) }, function () { - if (newProps.children.length && (this.isOpen || document.activeElement === this.refs.input)) { - if (!this.state.menu.children.length) { - return; - } - this.setState({ - isOpen: true - }, function () { - this.refs.list.scrollTop = 0; - }.bind(this)); - } else { - this.hideList(); - } - }.bind(this)); - }, - - /** - * We don't create the components, the user supplies them, - * so before rendering we attach handlers to facilitate communication from - * the ComboboxOption to the Combobox. - */ - makeMenu: function makeMenu(children) { - var activedescendant; - var isEmpty = true; - - // Should this instead use React.addons.cloneWithProps or React.cloneElement? - var _children = React.Children.map(children, function (child, index) { - // console.log(child.type, ComboboxOption.type) - if (child.type !== ComboboxOption || !child.props.isFocusable) { - // allow random elements to live in this list - return child; - } - isEmpty = false; - // TODO: cloneWithProps and map instead of altering the children in-place - var props = child.props; - var newProps = {}; - if (this.state.value === child.props.value) { - // need an ID for WAI-ARIA - newProps.id = props.id || 'ic-tokeninput-selected-' + ++guid; - newProps.isSelected = true; - activedescendant = props.id; - } - newProps.onBlur = this.handleOptionBlur; - newProps.onClick = this.selectOption.bind(this, child); - newProps.onFocus = this.handleOptionFocus; - newProps.onKeyDown = this.handleOptionKeyDown.bind(this, child); - newProps.onMouseEnter = this.handleOptionMouseEnter.bind(this, index); - - return React.cloneElement(child, newProps); - }.bind(this)); - - return { - children: _children, - activedescendant: activedescendant, - isEmpty: isEmpty - }; - }, - - getClassName: function getClassName() { - var className = addClass(this.props.className, 'ic-tokeninput'); - if (this.state.isOpen) className = addClass(className, 'ic-tokeninput-is-open'); - return className; - }, - - /** - * When the user begins typing again we need to clear out any state that has - * to do with an existing or potential selection. - */ - clearSelectedState: function clearSelectedState(cb) { - this.setState({ - focusedIndex: null, - inputValue: null, - value: null, - matchedAutocompleteOption: null, - activedescendant: null - }, cb); - }, - - handleInputChange: function handleInputChange() { - var value = this.refs.input.value; - this.clearSelectedState(function () { - this.props.onInput(value); - }.bind(this)); - }, - - handleInputFocus: function handleInputFocus() { - this.props.onFocus(); - this.maybeShowList(); - }, - - handleInputClick: function handleInputClick() { - this.maybeShowList(); - }, - - maybeShowList: function maybeShowList() { - if (this.props.showListOnFocus) { - this.showList(); - } - }, - - handleInputBlur: function handleInputBlur() { - var focusedAnOption = this.state.focusedIndex != null; - if (focusedAnOption) return; - this.maybeSelectAutocompletedOption(); - this.hideList(); - }, - - handleOptionBlur: function handleOptionBlur() { - // don't want to hide the list if we focused another option - this.blurTimer = setTimeout(this.hideList, 0); - }, - - handleOptionFocus: function handleOptionFocus() { - // see `handleOptionBlur` - clearTimeout(this.blurTimer); - }, - - handleInputKeyUp: function handleInputKeyUp(event) { - if (this.state.menu.isEmpty || - // autocompleting while backspacing feels super weird, so let's not - event.keyCode === 8 /*backspace*/ || !this.props.autocomplete.match(/both|inline/)) return; - }, - - handleButtonClick: function handleButtonClick() { - this.state.isOpen ? this.hideList() : this.showList(); - this.focusInput(); - }, - - showList: function showList() { - if (!this.state.menu.children.length) { - return; - } - this.setState({ isOpen: true }); - }, - - hideList: function hideList() { - this.setState({ - isOpen: false, - focusedIndex: null - }); - }, - - hideOnEscape: function hideOnEscape(event) { - this.hideList(); - this.focusInput(); - event.preventDefault(); - }, - - focusInput: function focusInput() { - this.refs.input.focus(); - }, - - selectInput: function selectInput() { - this.refs.input.select(); - }, - - inputKeydownMap: { - 8: 'removeLastToken', // delete - 13: 'selectOnEnter', // enter - 188: 'selectOnEnter', // comma - 27: 'hideOnEscape', // escape - 38: 'focusPrevious', // up arrow - 40: 'focusNext' // down arrow - }, - - optionKeydownMap: { - 13: 'selectOption', - 27: 'hideOnEscape', - 38: 'focusPrevious', - 40: 'focusNext' - }, - - handleKeydown: function handleKeydown(event) { - var handlerName = this.inputKeydownMap[event.keyCode]; - if (!handlerName) return; - this.setState({ usingKeyboard: true }); - return this[handlerName].call(this, event); - }, - - handleOptionKeyDown: function handleOptionKeyDown(child, event) { - var handlerName = this.optionKeydownMap[event.keyCode]; - if (!handlerName) { - // if the user starts typing again while focused on an option, move focus - // to the inpute, select so it wipes out any existing value - this.selectInput(); - return; - } - event.preventDefault(); - this.setState({ usingKeyboard: true }); - this[handlerName].call(this, child); - }, - - handleOptionMouseEnter: function handleOptionMouseEnter(index) { - if (this.state.usingKeyboard) this.setState({ usingKeyboard: false });else this.focusOptionAtIndex(index); - }, - - selectOnEnter: function selectOnEnter(event) { - event.preventDefault(); - this.maybeSelectAutocompletedOption(); - }, - - maybeSelectAutocompletedOption: function maybeSelectAutocompletedOption() { - if (!this.state.matchedAutocompleteOption) { - this.selectText(); - } else { - this.selectOption(this.state.matchedAutocompleteOption, { focus: false }); - } - }, - - selectOption: function selectOption(child, options) { - options = options || {}; - this.setState({ - // value: child.props.value, - // inputValue: getLabel(child), - matchedAutocompleteOption: null - }, function () { - this.props.onSelect(child.props.value, child); - this.hideList(); - this.clearSelectedState(); // added - if (options.focus !== false) this.selectInput(); - }.bind(this)); - this.refs.input.value = ''; // added - }, - - selectText: function selectText() { - var value = this.refs.input.value; - if (!value) return; - this.props.onSelect(value); - this.clearSelectedState(); - this.refs.input.value = ''; // added - }, - - focusNext: function focusNext(event) { - if (event.preventDefault) event.preventDefault(); - if (this.state.menu.isEmpty) return; - var index = this.nextFocusableIndex(this.state.focusedIndex); - this.focusOptionAtIndex(index); - }, - - removeLastToken: function removeLastToken() { - if (this.props.onRemoveLast && !this.refs.input.value) { - this.props.onRemoveLast(); - } - return true; - }, - - focusPrevious: function focusPrevious(event) { - if (event.preventDefault) event.preventDefault(); - if (this.state.menu.isEmpty) return; - var index = this.previousFocusableIndex(this.state.focusedIndex); - this.focusOptionAtIndex(index); - }, - - focusSelectedOption: function focusSelectedOption() { - var selectedIndex; - React.Children.forEach(this.props.children, function (child, index) { - if (child.props.value === this.state.value) selectedIndex = index; - }.bind(this)); - this.showList(); - this.setState({ - focusedIndex: selectedIndex - }, this.focusOption); - }, - - findInitialInputValue: function findInitialInputValue() { - // TODO: might not need this, we should know this in `makeMenu` - var inputValue; - React.Children.forEach(this.props.children, function (child) { - if (child.props.value === this.props.value) inputValue = getLabel(child); - }.bind(this)); - return inputValue; - }, - - clampIndex: function clampIndex(index) { - if (index < 0) { - return this.props.children.length - 1; - } else if (index >= this.props.children.length) { - return 0; - } - return index; - }, - - scanForFocusableIndex: function scanForFocusableIndex(index, increment) { - if (index === null || index === undefined) { - index = increment > 0 ? this.clampIndex(-1) : 0; - } - var newIndex = index; - while (true) { - newIndex = this.clampIndex(newIndex + increment); - if (newIndex === index || this.props.children[newIndex].props.isFocusable) { - return newIndex; - } - } - }, - - nextFocusableIndex: function nextFocusableIndex(index) { - return this.scanForFocusableIndex(index, 1); - }, - - previousFocusableIndex: function previousFocusableIndex(index) { - return this.scanForFocusableIndex(index, -1); - }, - - focusOptionAtIndex: function focusOptionAtIndex(index) { - if (!this.state.isOpen && this.state.value) return this.focusSelectedOption(); - this.showList(); - var length = this.props.children.length; - if (index === -1) index = length - 1;else if (index === length) index = 0; - this.setState({ - focusedIndex: index - }, this.focusOption); - }, - - focusOption: function focusOption() { - var index = this.state.focusedIndex; - this.refs.list.childNodes[index].focus(); - }, - - render: function render() { - var ariaLabel = this.props['aria-label'] || 'Start typing to search. ' + 'Press the down arrow to navigate results. If you don\'t find an ' + 'acceptable option, you can input an alternative. Once you find or ' + 'input the tag you want, press Enter or Comma to add it.'; - - return div({ className: this.getClassName() }, this.props.value, this.state.inputValue, input({ - ref: 'input', - autoComplete: 'off', - spellCheck: 'false', - 'aria-label': ariaLabel, - 'aria-expanded': this.state.isOpen + '', - 'aria-haspopup': 'true', - 'aria-activedescendant': this.state.menu.activedescendant, - 'aria-autocomplete': 'list', - 'aria-owns': this.state.listId, - id: this.props.id, - disabled: this.props.isDisabled, - className: 'ic-tokeninput-input', - onFocus: this.handleInputFocus, - onClick: this.handleInputClick, - onChange: this.handleInputChange, - onBlur: this.handleInputBlur, - onKeyDown: this.handleKeydown, - onKeyUp: this.handleInputKeyUp, - placeholder: this.props.placeholder, - role: 'combobox' - }), span({ - 'aria-hidden': 'true', - className: 'ic-tokeninput-button', - onClick: this.handleButtonClick - }, '▾'), div({ - id: this.state.listId, - ref: 'list', - className: 'ic-tokeninput-list', - role: 'listbox' - }, this.state.menu.children)); - } -}); - -function getLabel(component) { - return component.props.label || component.props.children; -} - -function matchFragment(userInput, firstChildLabel) { - userInput = userInput.toLowerCase(); - firstChildLabel = firstChildLabel.toLowerCase(); - if (userInput === '' || userInput === firstChildLabel) return false; - if (firstChildLabel.toLowerCase().indexOf(userInput.toLowerCase()) === -1) return false; - return true; -} \ No newline at end of file diff --git a/lib/index.js b/lib/index.js deleted file mode 100644 index beaf88d..0000000 --- a/lib/index.js +++ /dev/null @@ -1,38 +0,0 @@ -'use strict'; - -Object.defineProperty(exports, "__esModule", { - value: true -}); -exports.Token = exports.Option = exports.Combobox = undefined; - -var _combobox = require('./combobox'); - -var _combobox2 = _interopRequireDefault(_combobox); - -var _option = require('./option'); - -var _option2 = _interopRequireDefault(_option); - -var _token = require('./token'); - -var _token2 = _interopRequireDefault(_token); - -var _main = require('./main'); - -var _main2 = _interopRequireDefault(_main); - -function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - -exports.Combobox = _combobox2.default; -exports.Option = _option2.default; -exports.Token = _token2.default; - - -/** - * You can't do an import and then immediately export it :( - * And `export default TokenInput from './main'` doesn't seem to - * work either :( - * So this little variable swapping stuff gets it to work. - */ -var TokenInput = _main2.default; -exports.default = TokenInput; \ No newline at end of file diff --git a/lib/main.js b/lib/main.js deleted file mode 100644 index e134022..0000000 --- a/lib/main.js +++ /dev/null @@ -1,98 +0,0 @@ -'use strict'; - -var React = require('react'); -var Combobox = React.createFactory(require('./combobox')); -var Token = React.createFactory(require('./token')); -var classnames = require('classnames'); - -var ul = React.DOM.ul; -var li = React.DOM.li; - -module.exports = React.createClass({ - displayName: 'exports', - - propTypes: { - isLoading: React.PropTypes.bool, - loadingComponent: React.PropTypes.any, - onFocus: React.PropTypes.func, - onInput: React.PropTypes.func.isRequired, - onSelect: React.PropTypes.func.isRequired, - tokenAriaFunc: React.PropTypes.func, - onRemove: React.PropTypes.func.isRequired, - selected: React.PropTypes.array.isRequired, - menuContent: React.PropTypes.any, - showListOnFocus: React.PropTypes.bool, - placeholder: React.PropTypes.string - }, - - getInitialState: function getInitialState() { - return { - selectedToken: null - }; - }, - - handleClick: function handleClick() { - // TODO: Expand combobox API for focus - this.refs['combo-li'].querySelector('input').focus(); - }, - - handleFocus: function handleFocus() { - if (this.props.onFocus) { - this.props.onFocus(); - } - }, - - handleInput: function handleInput(inputValue) { - this.props.onInput(inputValue); - }, - - handleSelect: function handleSelect(event, option) { - var input = this.refs['combo-li'].querySelector('input'); - this.props.onSelect(event, option); - this.setState({ - selectedToken: null - }); - this.props.onInput(input.value); - }, - - handleRemove: function handleRemove(value) { - var input = this.refs['combo-li'].querySelector('input'); - this.props.onRemove(value); - input.focus(); - }, - - handleRemoveLast: function handleRemoveLast() { - this.props.onRemove(this.props.selected[this.props.selected.length - 1]); - }, - - render: function render() { - var isDisabled = this.props.isDisabled; - var tokens = this.props.selected.map(function (token) { - return Token({ - tokenAriaFunc: this.props.tokenAriaFunc, - onFocus: this.handleFocus, - onRemove: this.handleRemove, - value: token, - name: token.name, - key: token.id }); - }.bind(this)); - - var classes = classnames('ic-tokens flex', { - 'ic-tokens-disabled': isDisabled - }); - - return ul({ className: classes, onClick: this.handleClick }, tokens, li({ className: 'inline-flex', ref: 'combo-li' }, Combobox({ - id: this.props.id, - 'aria-label': this.props['combobox-aria-label'], - ariaDisabled: isDisabled, - onFocus: this.handleFocus, - onInput: this.handleInput, - showListOnFocus: this.props.showListOnFocus, - onSelect: this.handleSelect, - onRemoveLast: this.handleRemoveLast, - value: this.state.selectedToken, - isDisabled: isDisabled, - placeholder: this.props.placeholder - }, this.props.menuContent)), this.props.isLoading && li({ className: 'ic-tokeninput-loading flex' }, this.props.loadingComponent)); - } -}); \ No newline at end of file diff --git a/lib/option.js b/lib/option.js deleted file mode 100644 index f06e947..0000000 --- a/lib/option.js +++ /dev/null @@ -1,54 +0,0 @@ -'use strict'; - -var React = require('react'); -var addClass = require('./add-class'); -var div = React.createFactory('div'); - -module.exports = React.createClass({ - displayName: 'exports', - - - propTypes: { - - /** - * The value that will be sent to the `onSelect` handler of the - * parent Combobox. - */ - value: React.PropTypes.any.isRequired, - - /** - * What value to put into the input element when this option is - * selected, defaults to its children coerced to a string. - */ - label: React.PropTypes.string, - - /** - * Whether the element should be selectable - */ - isFocusable: React.PropTypes.bool - }, - - getDefaultProps: function getDefaultProps() { - return { - role: 'option', - tabIndex: '-1', - className: 'ic-tokeninput-option', - isSelected: false, - isFocusable: true - }; - }, - - render: function render() { - var props = this.props; - if (props.isSelected) { - props.className = addClass(props.className, 'ic-tokeninput-selected'); - props.ariaSelected = true; - } - return div({ - role: props.role, - tabIndex: props.tabIndex, - className: props.className - }); - } - -}); \ No newline at end of file diff --git a/lib/token.js b/lib/token.js deleted file mode 100644 index 9e3bcac..0000000 --- a/lib/token.js +++ /dev/null @@ -1,36 +0,0 @@ -'use strict'; - -var React = require('react'); -var span = React.DOM.span; -var li = React.createFactory('li'); - -module.exports = React.createClass({ - displayName: 'exports', - - handleClick: function handleClick() { - this.props.onRemove(this.props.value); - }, - - handleKeyDown: function handleKeyDown(key) { - var enterKey = 13; - if (key.keyCode === enterKey) this.props.onRemove(this.props.value); - }, - - ariaLabelRemove: function ariaLabelRemove() { - return this.props.tokenAriaFunc ? this.props.tokenAriaFunc(this.props.name) : 'Remove \'' + this.props.name + '\''; - }, - - render: function render() { - return li({ - className: "ic-token inline-flex" - }, span({ className: "ic-token-label" }, this.props.name), span({ - role: 'button', - onClick: this.handleClick, - onFocus: this.props.onFocus, - onKeyDown: this.handleKeyDown, - 'aria-label': this.ariaLabelRemove(), - className: "ic-token-delete-button", - tabIndex: 0 - }, "✕")); - } -}); \ No newline at end of file