Skip to content

Commit

Permalink
Implement allowCreate and newOptionCreator
Browse files Browse the repository at this point in the history
Added new Creatable HOC that wraps Select and adds support for creating new options. This HOC provides reasonable default behavior (eg create a new option on ENTER/TAB/comma) but also allows users to override defaults via props. No create-option-specific logic is inside of the base Select component and the logic within Creatable is fully customizable.

Added new onInputKeyDown prop to Select (a mirror to onInputChange) that allows users to tap into key-down events and prevent default Select handling. (This is key to how new options are created.)

Pulled default filterOptions and menuRenderer props out of Select and into separate modules. I think this lines up with @JedWatson's long-term goals. It was also necessary to enable better sharing between the base Select and the new Creatable HOC.
  • Loading branch information
bvaughn committed Sep 4, 2016
1 parent f4496df commit 7fe723f
Show file tree
Hide file tree
Showing 8 changed files with 636 additions and 103 deletions.
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,29 @@ var isLoadingExternally = true;
/>
```

### User-created tags

The `Creatable` component enables users to create new tags within react-select.
It decorates a `Select` and so it supports all of the default properties (eg single/multi mode, filtering, etc) in addition to a couple of custom ones (shown below).
The easiest way to use it is like so:

```js
import { Creatable } from 'react-select';

function render (selectProps) {
return <Creatable {...selectProps} />;
};
```

##### Creatable properties

Property | Type | Description
:---|:---|:---
`isOptionUnique` | function | Searches for any matching option within the set of options. This function prevents duplicate options from being created. By default this is a basic, case-sensitive comparison of label and value. Expected signature: `(newOption: Object, options: Array, labelKey: string, valueKey: string): boolean` |
`isValidNewOption` | function | Determines if the current input text represents a valid option. By default any non-empty string will be considered valid. Expected signature: `(label: string): boolean` |
`newOptionCreator` | function | Factory to create new option. Expected signature: `(label: string, labelKey: string, valueKey: string): Object` |
`shouldKeyDownEventCreateNewOption` | function | Decides if a keyDown event (eg its `keyCode`) should result in the creation of a new option. ENTER, TAB and comma keys create new options by dfeault. Expected signature: `(keyCode: number): boolean` |

### Filtering options

You can control how options are filtered with the following properties:
Expand Down Expand Up @@ -263,7 +286,6 @@ function cleanInput(inputValue) {
Property | Type | Default | Description
:-----------------------|:--------------|:--------------|:--------------------------------
addLabelText | string | 'Add "{label}"?' | text to display when `allowCreate` is true
allowCreate | bool | false | allow new options to be created in multi mode (displays an "Add \<option> ?" item when a value not already in the `options` array is entered) [NOTE: not available in 1.0.0-beta]
autoBlur | bool | false | Blurs the input element after a selection has been made. Handy for lowering the keyboard on mobile devices
autofocus | bool | undefined | autofocus the component on mount
autoload | bool | true | whether to auto-load the default async options set
Expand Down Expand Up @@ -291,7 +313,6 @@ function cleanInput(inputValue) {
menuRenderer | func | undefined | Renders a custom menu with options; accepts the following named parameters: `menuRenderer({ focusedOption, focusOption, options, selectValue, valueArray })`
multi | bool | undefined | multi-value input
name | string | undefined | field name, for hidden `<input />` tag
newOptionCreator | func | undefined | factory to create new options when `allowCreate` is true [NOTE: not available in 1.0.0-beta]
noResultsText | string | 'No results found' | placeholder displayed when there are no matching search results or a falsy value to hide it
onBlur | func | undefined | onBlur handler: `function(event) {}`
onBlurResetsInput | bool | true | whether to clear input on blur or not
Expand Down
8 changes: 5 additions & 3 deletions examples/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React from 'react';
import ReactDOM from 'react-dom';
import Select from 'react-select';

import Creatable from './components/Creatable';
import Contributors from './components/Contributors';
import GithubUsers from './components/GithubUsers';
import CustomComponents from './components/CustomComponents';
Expand All @@ -23,9 +24,10 @@ ReactDOM.render(
<NumericSelect label="Numeric Values" />
<CustomRender label="Custom Render Methods"/>
<CustomComponents label="Custom Placeholder, Option and Value Components" />
{/*
<SelectedValuesField label="Option Creation (tags mode)" options={FLAVOURS} allowCreate hint="Enter a value that's NOT in the list, then hit return" />
*/}
<Creatable
hint="Enter a value that's NOT in the list, then hit return"
label="Custom tag creation"
/>
</div>,
document.getElementById('example')
);
67 changes: 67 additions & 0 deletions examples/src/components/Creatable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React from 'react';
import Select from 'react-select';

var CreatableDemo = React.createClass({
displayName: 'CreatableDemo',
propTypes: {
hint: React.PropTypes.string,
label: React.PropTypes.string
},
getInitialState () {
return {
multi: true,
multiValue: [],
options: [
{ value: 'R', label: 'Red' },
{ value: 'G', label: 'Green' },
{ value: 'B', label: 'Blue' }
],
value: undefined
};
},
handleOnChange (value) {
const { multi } = this.state;
if (multi) {
this.setState({ multiValue: value });
} else {
this.setState({ value });
}
},
render () {
const { multi, multiValue, options, value } = this.state;
return (
<div className="section">
<h3 className="section-heading">{this.props.label}</h3>
<Select.Creatable
multi={multi}
options={options}
onChange={this.handleOnChange}
value={multi ? multiValue : value}
/>
<div className="hint">{this.props.hint}</div>
<div className="checkbox-list">
<label className="checkbox">
<input
type="radio"
className="checkbox-control"
checked={multi}
onChange={() => this.setState({ multi: true })}
/>
<span className="checkbox-label">Multiselect</span>
</label>
<label className="checkbox">
<input
type="radio"
className="checkbox-control"
checked={!multi}
onChange={() => this.setState({ multi: false })}
/>
<span className="checkbox-label">Single Value</span>
</label>
</div>
</div>
);
}
});

module.exports = CreatableDemo;
212 changes: 212 additions & 0 deletions src/Creatable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import React from 'react';
import Select from './Select';
import defaultFilterOptions from './utils/defaultFilterOptions';
import defaultMenuRenderer from './utils/defaultMenuRenderer';

const Creatable = React.createClass({
displayName: 'CreatableSelect',

propTypes: {
// See Select.propTypes.filterOptions
filterOptions: React.PropTypes.any,

// Searches for any matching option within the set of options.
// This function prevents duplicate options from being created.
// (newOption: Object, options: Array, labelKey: string, valueKey: string): boolean
isOptionUnique: React.PropTypes.func,

// Determines if the current input text represents a valid option.
// (label: string): boolean
isValidNewOption: React.PropTypes.func,

// See Select.propTypes.menuRenderer
menuRenderer: React.PropTypes.any,

// Factory to create new option.
// (label: string, labelKey: string, valueKey: string): Object
newOptionCreator: React.PropTypes.func,

// Creates prompt/placeholder option text.
// (filterText: string): string
promptTextCreator: React.PropTypes.func,

// Decides if a keyDown event (eg its `keyCode`) should result in the creation of a new option.
shouldKeyDownEventCreateNewOption: React.PropTypes.func,
},

// Default prop methods
statics: {
isOptionUnique,
isValidNewOption,
newOptionCreator,
promptTextCreator,
shouldKeyDownEventCreateNewOption
},

getDefaultProps () {
return {
filterOptions: defaultFilterOptions,
isOptionUnique,
isValidNewOption,
menuRenderer: defaultMenuRenderer,
newOptionCreator,
promptTextCreator,
shouldKeyDownEventCreateNewOption,
};
},

createNewOption () {
const { isValidNewOption, newOptionCreator, shouldKeyDownEventCreateNewOption } = this.props;
const { labelKey, options, valueKey } = this.select.props;

const inputValue = this.select.getInputValue();

if (isValidNewOption(inputValue)) {
const newOption = newOptionCreator(inputValue, labelKey, valueKey);
const isOptionUnique = this.isOptionUnique({ newOption });

// Don't add the same option twice.
if (isOptionUnique) {
options.unshift(newOption);

this.select.selectValue(newOption);
}
}
},

filterOptions (...params) {
const { filterOptions, isValidNewOption, promptTextCreator } = this.props;

const filteredOptions = filterOptions(...params);

const inputValue = this.select
? this.select.getInputValue()
: '';

if (isValidNewOption(inputValue)) {
const { newOptionCreator } = this.props;
const { labelKey, options, valueKey } = this.select.props;

const newOption = newOptionCreator(inputValue, labelKey, valueKey);

// TRICKY Compare to all options (not just filtered options) in case option has already been selected).
// For multi-selects, this would remove it from the filtered list.
const isOptionUnique = this.isOptionUnique({
newOption,
options
});

if (isOptionUnique) {
const prompt = promptTextCreator(inputValue);

this._createPlaceholderOption = newOptionCreator(prompt, labelKey, valueKey);

filteredOptions.unshift(this._createPlaceholderOption);
}
}

return filteredOptions;
},

isOptionUnique ({
newOption,
options
}) {
if (!this.select) {
return false;
}

const { isOptionUnique } = this.props;
const { labelKey, valueKey } = this.select.props;

options = options || this.select.filterOptions();

return isOptionUnique(newOption, options, labelKey, valueKey);
},

menuRenderer (params) {
const { menuRenderer } = this.props;

return menuRenderer({
...params,
onSelect: this.onOptionSelect
});
},

onInputKeyDown (event) {
const { shouldKeyDownEventCreateNewOption } = this.props;
const focusedOption = this.select.getFocusedOption();

if (
focusedOption &&
focusedOption === this._createPlaceholderOption &&
shouldKeyDownEventCreateNewOption(event.keyCode)
) {
this.createNewOption();

// Prevent decorated Select from doing anything additional with this keyDown event
event.preventDefault();
}
},

onOptionSelect (option, event) {
if (option === this._createPlaceholderOption) {
this.createNewOption();
} else {
this.select.selectValue(option);
}
},

render () {
const { newOptionCreator, shouldKeyDownEventCreateNewOption, ...restProps } = this.props;

return (
<Select
{...restProps}
allowCreate
filterOptions={this.filterOptions}
menuRenderer={this.menuRenderer}
onInputKeyDown={this.onInputKeyDown}
ref={(ref) => 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;
Loading

4 comments on commit 7fe723f

@stinoga
Copy link

@stinoga stinoga commented on 7fe723f Sep 7, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bvaughn This is awesome! Can you use this with async? What's the best way to pull the options from a REST api?

@bvaughn
Copy link
Collaborator Author

@bvaughn bvaughn commented on 7fe723f Sep 7, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not yet! Hope to add support for that combo soon. Traveling in China this week and my time is kind of limited. :)

@bvaughn
Copy link
Collaborator Author

@bvaughn bvaughn commented on 7fe723f Sep 7, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PS Thanks 😁

@stinoga
Copy link

@stinoga stinoga commented on 7fe723f Sep 7, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bvaughn Awesome work dude. And no hurry on it, enjoy your trip!

Please sign in to comment.