this.select = ref}
+ />
+ );
+ }
+});
+
+function isOptionUnique (newOption, options, labelKey, valueKey) {
+ return options
+ .filter((option) =>
+ option[labelKey] === newOption[labelKey] ||
+ option[valueKey] === newOption[valueKey]
+ )
+ .length === 0;
+};
+
+function isValidNewOption (label) {
+ return !!label;
+};
+
+function newOptionCreator (label, labelKey, valueKey) {
+ const option = {};
+ option[valueKey] = label;
+ option[labelKey] = label;
+ option.className = 'Select-create-option-placeholder';
+ return option;
+};
+
+function promptTextCreator (label) {
+ return `Create option "${label}"`;
+}
+
+function shouldKeyDownEventCreateNewOption (keyCode, label) {
+ switch (keyCode) {
+ case 9: // TAB
+ case 13: // ENTER
+ case 188: // COMMA
+ return true;
+ }
+
+ return false;
+};
+
+module.exports = Creatable;
diff --git a/src/Select.js b/src/Select.js
index 19717a1a6a..29c7ab1241 100644
--- a/src/Select.js
+++ b/src/Select.js
@@ -3,9 +3,11 @@ import ReactDOM from 'react-dom';
import Input from 'react-input-autosize';
import classNames from 'classnames';
-import stripDiacritics from './utils/stripDiacritics';
+import defaultFilterOptions from './utils/defaultFilterOptions';
+import defaultMenuRenderer from './utils/defaultMenuRenderer';
import Async from './Async';
+import Creatable from './Creatable';
import Option from './Option';
import Value from './Value';
@@ -34,15 +36,14 @@ const Select = React.createClass({
propTypes: {
addLabelText: React.PropTypes.string, // placeholder displayed when you want to add a label on a multi-value input
- allowCreate: React.PropTypes.bool, // whether to allow creation of new entries
- 'aria-label': React.PropTypes.string, // Aria label (for assistive tech)
+ 'aria-label': React.PropTypes.string, // Aria label (for assistive tech)
'aria-labelledby': React.PropTypes.string, // HTML ID of an element that should be used as the label (for assistive tech)
autoBlur: React.PropTypes.bool, // automatically blur the component when an option is selected
autofocus: React.PropTypes.bool, // autofocus the component on mount
autosize: React.PropTypes.bool, // whether to enable autosizing or not
backspaceRemoves: React.PropTypes.bool, // whether backspace removes an item if there is no text input
backspaceToRemoveMessage: React.PropTypes.string, // Message to use for screenreaders to press backspace to remove the current item -
- // {label} is replaced with the item label
+ // {label} is replaced with the item label
className: React.PropTypes.string, // className for the outer element
clearAllText: stringOrNode, // title for the "clear" control when multi: true
clearValueText: stringOrNode, // title for the "clear" control
@@ -68,7 +69,6 @@ const Select = React.createClass({
menuStyle: React.PropTypes.object, // optional style to apply to the menu
multi: React.PropTypes.bool, // multi-value input
name: React.PropTypes.string, // generates a hidden tag with this field name for html forms
- newOptionCreator: React.PropTypes.func, // factory to create new options when allowCreate set
noResultsText: stringOrNode, // placeholder displayed when there are no matching search results
onBlur: React.PropTypes.func, // onBlur handler: function (event) {}
onBlurResetsInput: React.PropTypes.bool, // whether input is cleared on blur
@@ -77,10 +77,11 @@ const Select = React.createClass({
onCloseResetsInput: React.PropTypes.bool, // whether input is cleared when menu is closed through the arrow
onFocus: React.PropTypes.func, // onFocus handler: function (event) {}
onInputChange: React.PropTypes.func, // onInputChange handler: function (inputValue) {}
+ onInputKeyDown: React.PropTypes.func, // input keyDown handler: function (event) {}
onMenuScrollToBottom: React.PropTypes.func, // fires when the menu is scrolled to the bottom; can be used to paginate options
onOpen: React.PropTypes.func, // fires when the menu is opened
onValueClick: React.PropTypes.func, // onClick handler for value labels: function (value, event) {}
- openAfterFocus: React.PropTypes.bool, // boolean to enable opening dropdown when focused
+ openAfterFocus: React.PropTypes.bool, // boolean to enable opening dropdown when focused
openOnFocus: React.PropTypes.bool, // always open options menu on focus
optionClassName: React.PropTypes.string, // additional class(es) to apply to the elements
optionComponent: React.PropTypes.func, // option component to render in dropdown
@@ -103,13 +104,12 @@ const Select = React.createClass({
wrapperStyle: React.PropTypes.object, // optional style to apply to the component wrapper
},
- statics: { Async },
+ statics: { Async, Creatable },
getDefaultProps () {
return {
addLabelText: 'Add "{label}"?',
autosize: true,
- allowCreate: false,
backspaceRemoves: true,
backspaceToRemoveMessage: 'Press backspace to remove {label}',
clearable: true,
@@ -118,7 +118,7 @@ const Select = React.createClass({
delimiter: ',',
disabled: false,
escapeClearsValue: true,
- filterOptions: true,
+ filterOptions: defaultFilterOptions,
ignoreAccents: true,
ignoreCase: true,
inputProps: {},
@@ -128,6 +128,7 @@ const Select = React.createClass({
matchPos: 'any',
matchProp: 'any',
menuBuffer: 0,
+ menuRenderer: defaultMenuRenderer,
multi: false,
noResultsText: 'No results found',
onBlurResetsInput: true,
@@ -399,6 +400,7 @@ const Select = React.createClass({
handleInputChange (event) {
let newInputValue = event.target.value;
+
if (this.state.inputValue !== event.target.value && this.props.onInputChange) {
let nextState = this.props.onInputChange(newInputValue);
// Note: != used deliberately here to catch undefined and null
@@ -406,6 +408,7 @@ const Select = React.createClass({
newInputValue = '' + nextState;
}
}
+
this.setState({
isOpen: true,
isPseudoFocused: false,
@@ -415,6 +418,14 @@ const Select = React.createClass({
handleKeyDown (event) {
if (this.props.disabled) return;
+
+ if (typeof this.props.onInputKeyDown === 'function') {
+ this.props.onInputKeyDown(event);
+ if (event.defaultPrevented) {
+ return;
+ }
+ }
+
switch (event.keyCode) {
case 8: // backspace
if (!this.state.inputValue && this.props.backspaceRemoves) {
@@ -460,15 +471,6 @@ const Select = React.createClass({
case 36: // home key
this.focusStartOption();
break;
- // case 188: // ,
- // if (this.props.allowCreate && this.props.multi) {
- // event.preventDefault();
- // event.stopPropagation();
- // this.selectFocusedOption();
- // } else {
- // return;
- // }
- // break;
default: return;
}
event.preventDefault();
@@ -547,7 +549,7 @@ const Select = React.createClass({
},
selectValue (value) {
- //NOTE: update value in the callback to make sure the input value is empty so that there are no sttyling issues (Chrome had issue otherwise)
+ //NOTE: update value in the callback to make sure the input value is empty so that there are no styling issues (Chrome had issue otherwise)
this.hasScrolledToOption = false;
if (this.props.multi) {
this.setState({
@@ -699,10 +701,15 @@ const Select = React.createClass({
});
},
+ getFocusedOption () {
+ return this._focusedOption;
+ },
+
+ getInputValue () {
+ return this.state.inputValue;
+ },
+
selectFocusedOption () {
- // if (this.props.allowCreate && !this.state.focusedOption) {
- // return this.selectValue(this.state.inputValue);
- // }
if (this._focusedOption) {
return this.selectValue(this._focusedOption);
}
@@ -849,38 +856,26 @@ const Select = React.createClass({
filterOptions (excludeOptions) {
var filterValue = this.state.inputValue;
var options = this.props.options || [];
- if (typeof this.props.filterOptions === 'function') {
- return this.props.filterOptions.call(this, options, filterValue, excludeOptions);
- } else if (this.props.filterOptions) {
- if (this.props.ignoreAccents) {
- filterValue = stripDiacritics(filterValue);
- }
- if (this.props.ignoreCase) {
- filterValue = filterValue.toLowerCase();
- }
- if (excludeOptions) excludeOptions = excludeOptions.map(i => i[this.props.valueKey]);
- return options.filter(option => {
- if (excludeOptions && excludeOptions.indexOf(option[this.props.valueKey]) > -1) return false;
- if (this.props.filterOption) return this.props.filterOption.call(this, option, filterValue);
- if (!filterValue) return true;
- var valueTest = String(option[this.props.valueKey]);
- var labelTest = String(option[this.props.labelKey]);
- if (this.props.ignoreAccents) {
- if (this.props.matchProp !== 'label') valueTest = stripDiacritics(valueTest);
- if (this.props.matchProp !== 'value') labelTest = stripDiacritics(labelTest);
- }
- if (this.props.ignoreCase) {
- if (this.props.matchProp !== 'label') valueTest = valueTest.toLowerCase();
- if (this.props.matchProp !== 'value') labelTest = labelTest.toLowerCase();
+ if (this.props.filterOptions) {
+ // Maintain backwards compatibility with boolean attribute
+ const filterOptions = typeof this.props.filterOptions === 'function'
+ ? this.props.filterOptions
+ : defaultFilterOptions;
+
+ return filterOptions(
+ options,
+ filterValue,
+ excludeOptions,
+ {
+ filterOption: this.props.filterOption,
+ ignoreAccents: this.props.ignoreAccents,
+ ignoreCase: this.props.ignoreCase,
+ labelKey: this.props.labelKey,
+ matchPos: this.props.matchPos,
+ matchProp: this.props.matchProp,
+ valueKey: this.props.valueKey,
}
- return this.props.matchPos === 'start' ? (
- (this.props.matchProp !== 'label' && valueTest.substr(0, filterValue.length) === filterValue) ||
- (this.props.matchProp !== 'value' && labelTest.substr(0, filterValue.length) === filterValue)
- ) : (
- (this.props.matchProp !== 'label' && valueTest.indexOf(filterValue) >= 0) ||
- (this.props.matchProp !== 'value' && labelTest.indexOf(filterValue) >= 0)
- );
- });
+ );
} else {
return options;
}
@@ -888,49 +883,21 @@ const Select = React.createClass({
renderMenu (options, valueArray, focusedOption) {
if (options && options.length) {
- if (this.props.menuRenderer) {
- return this.props.menuRenderer({
- focusedOption,
- focusOption: this.focusOption,
- labelKey: this.props.labelKey,
- options,
- selectValue: this.selectValue,
- valueArray,
- });
- } else {
- let Option = this.props.optionComponent;
- let renderLabel = this.props.optionRenderer || this.getOptionLabel;
-
- return options.map((option, i) => {
- let isSelected = valueArray && valueArray.indexOf(option) > -1;
- let isFocused = option === focusedOption;
- let optionRef = isFocused ? 'focused' : null;
- let optionClass = classNames(this.props.optionClassName, {
- 'Select-option': true,
- 'is-selected': isSelected,
- 'is-focused': isFocused,
- 'is-disabled': option.disabled,
- });
-
- return (
-
- {renderLabel(option, i)}
-
- );
- });
- }
+ return this.props.menuRenderer({
+ focusedOption,
+ focusOption: this.focusOption,
+ instancePrefix: this._instancePrefix,
+ labelKey: this.props.labelKey,
+ onFocus: this.focusOption,
+ onSelect: this.selectValue,
+ optionClassName: this.props.optionClassName,
+ optionComponent: this.props.optionComponent,
+ optionRenderer: this.props.optionRenderer || this.getOptionLabel,
+ options,
+ selectValue: this.selectValue,
+ valueArray,
+ valueKey: this.props.valueKey,
+ });
} else if (this.props.noResultsText) {
return (
@@ -1003,14 +970,14 @@ const Select = React.createClass({
render () {
let valueArray = this.getValueArray(this.props.value);
- let options = this._visibleOptions = this.filterOptions(this.props.multi ? valueArray : null);
+ let options = this._visibleOptions = this.filterOptions(this.props.multi ? this.getValueArray(this.props.value) : null);
let isOpen = this.state.isOpen;
if (this.props.multi && !options.length && valueArray.length && !this.state.inputValue) isOpen = false;
const focusedOptionIndex = this.getFocusableOptionIndex(valueArray[0]);
let focusedOption = null;
if (focusedOptionIndex !== null) {
- focusedOption = this._focusedOption = this._visibleOptions[focusedOptionIndex];
+ focusedOption = this._focusedOption = options[focusedOptionIndex];
} else {
focusedOption = this._focusedOption = null;
}
diff --git a/src/utils/defaultFilterOptions.js b/src/utils/defaultFilterOptions.js
new file mode 100644
index 0000000000..8a5a783a1b
--- /dev/null
+++ b/src/utils/defaultFilterOptions.js
@@ -0,0 +1,38 @@
+import stripDiacritics from './stripDiacritics';
+
+function filterOptions (options, filterValue, excludeOptions, props) {
+ if (props.ignoreAccents) {
+ filterValue = stripDiacritics(filterValue);
+ }
+
+ if (props.ignoreCase) {
+ filterValue = filterValue.toLowerCase();
+ }
+
+ if (excludeOptions) excludeOptions = excludeOptions.map(i => i[props.valueKey]);
+
+ return options.filter(option => {
+ if (excludeOptions && excludeOptions.indexOf(option[props.valueKey]) > -1) return false;
+ if (props.filterOption) return props.filterOption.call(this, option, filterValue);
+ if (!filterValue) return true;
+ var valueTest = String(option[props.valueKey]);
+ var labelTest = String(option[props.labelKey]);
+ if (props.ignoreAccents) {
+ if (props.matchProp !== 'label') valueTest = stripDiacritics(valueTest);
+ if (props.matchProp !== 'value') labelTest = stripDiacritics(labelTest);
+ }
+ if (props.ignoreCase) {
+ if (props.matchProp !== 'label') valueTest = valueTest.toLowerCase();
+ if (props.matchProp !== 'value') labelTest = labelTest.toLowerCase();
+ }
+ return props.matchPos === 'start' ? (
+ (props.matchProp !== 'label' && valueTest.substr(0, filterValue.length) === filterValue) ||
+ (props.matchProp !== 'value' && labelTest.substr(0, filterValue.length) === filterValue)
+ ) : (
+ (props.matchProp !== 'label' && valueTest.indexOf(filterValue) >= 0) ||
+ (props.matchProp !== 'value' && labelTest.indexOf(filterValue) >= 0)
+ );
+ });
+}
+
+module.exports = filterOptions;
diff --git a/src/utils/defaultMenuRenderer.js b/src/utils/defaultMenuRenderer.js
new file mode 100644
index 0000000000..8154d7c4b8
--- /dev/null
+++ b/src/utils/defaultMenuRenderer.js
@@ -0,0 +1,50 @@
+import classNames from 'classnames';
+import React from 'react';
+
+function menuRenderer ({
+ focusedOption,
+ instancePrefix,
+ labelKey,
+ onFocus,
+ onSelect,
+ optionClassName,
+ optionComponent,
+ optionRenderer,
+ options,
+ valueArray,
+ valueKey,
+}) {
+ let Option = optionComponent;
+
+ return options.map((option, i) => {
+ let isSelected = valueArray && valueArray.indexOf(option) > -1;
+ let isFocused = option === focusedOption;
+ let optionRef = isFocused ? 'focused' : null;
+ let optionClass = classNames(optionClassName, {
+ 'Select-option': true,
+ 'is-selected': isSelected,
+ 'is-focused': isFocused,
+ 'is-disabled': option.disabled,
+ });
+
+ return (
+
+ {optionRenderer(option, i)}
+
+ );
+ });
+}
+
+module.exports = menuRenderer;
diff --git a/test/Creatable-test.js b/test/Creatable-test.js
new file mode 100644
index 0000000000..e4732d40d9
--- /dev/null
+++ b/test/Creatable-test.js
@@ -0,0 +1,176 @@
+'use strict';
+/* global describe, it, beforeEach */
+/* eslint react/jsx-boolean-value: 0 */
+
+// Copied from Async-test verbatim; may need to be reevaluated later.
+var jsdomHelper = require('../testHelpers/jsdomHelper');
+jsdomHelper();
+var unexpected = require('unexpected');
+var unexpectedDom = require('unexpected-dom');
+var unexpectedReact = require('unexpected-react');
+var expect = unexpected
+ .clone()
+ .installPlugin(unexpectedDom)
+ .installPlugin(unexpectedReact);
+
+var React = require('react');
+var ReactDOM = require('react-dom');
+var TestUtils = require('react-addons-test-utils');
+var Select = require('../src/Select');
+
+describe('Creatable', () => {
+ let creatableInstance, creatableNode, filterInputNode, innserSelectInstance, renderer;
+
+ beforeEach(() => renderer = TestUtils.createRenderer());
+
+ const defaultOptions = [
+ { value: 'one', label: 'One' },
+ { value: 'two', label: '222' },
+ { value: 'three', label: 'Three' },
+ { value: 'four', label: 'AbcDef' }
+ ];
+
+ function createControl (props = {}) {
+ props.options = props.options || defaultOptions;
+ creatableInstance = TestUtils.renderIntoDocument(
+
+ );
+ creatableNode = ReactDOM.findDOMNode(creatableInstance);
+ innserSelectInstance = creatableInstance.select;
+ findAndFocusInputControl();
+ };
+
+ function findAndFocusInputControl () {
+ filterInputNode = creatableNode.querySelector('input');
+ if (filterInputNode) {
+ TestUtils.Simulate.focus(filterInputNode);
+ }
+ };
+
+ function typeSearchText (text) {
+ TestUtils.Simulate.change(filterInputNode, { target: { value: text } });
+ };
+
+ it('should render a decorated Select (with passed through properties)', () => {
+ createControl({
+ inputProps: {
+ className: 'foo'
+ }
+ });
+ expect(creatableNode.querySelector('.Select-input'), 'to have attributes', {
+ class: ['foo']
+ });
+ });
+
+ it('should add a placeholder "create..." prompt when filter text is entered that does not match any existing options', () => {
+ createControl();
+ typeSearchText('foo');
+ expect(creatableNode.querySelector('.Select-create-option-placeholder'), 'to have text', Select.Creatable.promptTextCreator('foo'));
+ });
+
+ it('should not show a "create..." prompt if current filter text is an exact match for an existing option', () => {
+ createControl({
+ isOptionUnique: () => false
+ });
+ typeSearchText('existing');
+ expect(creatableNode.querySelector('.Select-menu-outer').textContent, 'not to equal', Select.Creatable.promptTextCreator('existing'));
+ });
+
+ it('should not show a "create..." prompt if current filter text is not a valid option (as determined by :isValidNewOption prop)', () => {
+ createControl({
+ isValidNewOption: () => false
+ });
+ typeSearchText('invalid');
+ expect(creatableNode.querySelector('.Select-menu-outer').textContent, 'not to equal', Select.Creatable.promptTextCreator('invalid'));
+ });
+
+ it('should create (and auto-select) a new option when placeholder option is clicked', () => {
+ let selectedOption;
+ const options = [];
+ createControl({
+ onChange: (option) => selectedOption = option,
+ options
+ });
+ typeSearchText('foo');
+ TestUtils.Simulate.mouseDown(creatableNode.querySelector('.Select-create-option-placeholder'));
+ expect(options, 'to have length', 1);
+ expect(options[0].label, 'to equal', 'foo');
+ expect(selectedOption, 'to be', options[0]);
+ });
+
+ it('should create (and auto-select) a new option when ENTER is pressed while placeholder option is selected', () => {
+ let selectedOption;
+ const options = [];
+ createControl({
+ onChange: (option) => selectedOption = option,
+ options,
+ shouldKeyDownEventCreateNewOption: () => true
+ });
+ typeSearchText('foo');
+ TestUtils.Simulate.keyDown(filterInputNode, { keyCode: 13 });
+ expect(options, 'to have length', 1);
+ expect(options[0].label, 'to equal', 'foo');
+ expect(selectedOption, 'to be', options[0]);
+ });
+
+ it('should not create a new option if the placeholder option is not selected but should select the focused option', () => {
+ const options = [{ label: 'One', value: 1 }];
+ createControl({
+ options,
+ shouldKeyDownEventCreateNewOption: (keyCode) => keyCode === 13
+ });
+ typeSearchText('on'); // ['Create option "on"', 'One']
+ TestUtils.Simulate.keyDown(filterInputNode, { keyCode: 40, key: 'ArrowDown' }); // Select 'One'
+ TestUtils.Simulate.keyDown(filterInputNode, { keyCode: 13 });
+ expect(options, 'to have length', 1);
+ });
+
+ it('default :isOptionUnique function should do a simple equality check for value and label', () => {
+ const options = [
+ newOption('foo', 1),
+ newOption('bar', 2),
+ newOption('baz', 3)
+ ];
+
+ function newOption (label, value) {
+ return { label, value };
+ };
+
+ function test (newOption) {
+ return Select.Creatable.isOptionUnique(newOption, options, 'label', 'value');
+ };
+
+ expect(test(newOption('foo', 0)), 'to be', false);
+ expect(test(newOption('qux', 1)), 'to be', false);
+ expect(test(newOption('qux', 4)), 'to be', true);
+ expect(test(newOption('Foo', 11)), 'to be', true);
+ });
+
+ it('default :isValidNewOption function should just ensure a non-empty string is provided', () => {
+ function test (label) {
+ return Select.Creatable.isValidNewOption(label);
+ };
+
+ expect(test(''), 'to be', false);
+ expect(test('a'), 'to be', true);
+ expect(test(' '), 'to be', true);
+ });
+
+ it('default :newOptionCreator function should create an option with a :label and :value equal to the label string', () => {
+ const option = Select.Creatable.newOptionCreator('foo', 'label', 'value');
+ expect(option.className, 'to equal', 'Select-create-option-placeholder');
+ expect(option.label, 'to equal', 'foo');
+ expect(option.value, 'to equal', 'foo');
+ });
+
+ it('default :shouldKeyDownEventCreateNewOption function should accept TAB, ENTER, and comma keys', () => {
+ function test (keyCode) {
+ return Select.Creatable.shouldKeyDownEventCreateNewOption(keyCode);
+ };
+
+ expect(test(9), 'to be', true);
+ expect(test(13), 'to be', true);
+ expect(test(188), 'to be', true);
+ expect(test(1), 'to be', false);
+ });
+});