Last active
August 29, 2015 14:17
-
-
Save piecyk/14cfd932aabd1989ff10 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var React = require('react'); | |
var Input = require('./AutosizeInput'); | |
var classes = require('./classnames'); | |
var Value = require('./Value'); | |
var requestId = 0; | |
var DEFAULT_CLASSES = { | |
Select: 'Select', | |
SelectNoresults: 'Select-noresults', | |
SelectPlaceholder: 'Select-placeholder', | |
SelectLoading: 'Select-loading', | |
SelectClear: 'Select-clear', | |
SelectMenu: 'Select-menu', | |
SelectMenuOuter: 'Select-menu-outer', | |
SelectInput: 'Select-input', | |
SelectControl: 'Select-control', | |
SelectArrow: 'Select-arrow', | |
SelectOption: 'Select-option', | |
SelectItem: 'Select-item', | |
SelectItemIcon: 'Select-item-icon', | |
SelectItemLabel: 'Select-item-label', | |
SelectItemLabel__a: 'Select-item-label__a', | |
isMulti: 'is-multi', | |
isSearchable: 'is-searchable', | |
isOpen: 'is-open', | |
isFocused: 'is-focused', | |
isLoading:'is-loading', | |
isDisabled: 'is-disabled', | |
hasValue: 'has-value' | |
}; | |
var Select = React.createClass({ | |
displayName: 'Select', | |
propTypes: { | |
value: React.PropTypes.any, // initial field value | |
multi: React.PropTypes.bool, // multi-value input | |
disabled: React.PropTypes.bool, // whether the Select is disabled or not | |
options: React.PropTypes.array, // array of options | |
delimiter: React.PropTypes.string, // delimiter to use to join multiple values | |
asyncOptions: React.PropTypes.func, // function to call to get options | |
autoload: React.PropTypes.bool, // whether to auto-load the default async options set | |
placeholder: React.PropTypes.string, // field placeholder, displayed when there's no value | |
noResultsText: React.PropTypes.string, // placeholder displayed when there are no matching search results | |
clearable: React.PropTypes.bool, // should it be possible to reset value | |
clearValueText: React.PropTypes.string, // title for the "clear" control | |
clearAllText: React.PropTypes.string, // title for the "clear" control when multi: true | |
searchable: React.PropTypes.bool, // whether to enable searching feature or not | |
searchPromptText: React.PropTypes.string, // label to prompt for search input | |
name: React.PropTypes.string, // field name, for hidden <input /> tag | |
onChange: React.PropTypes.func, // onChange handler: function(newValue) {} | |
onFocus: React.PropTypes.func, // onFocus handler: function(event) {} | |
onBlur: React.PropTypes.func, // onBlur handler: function(event) {} | |
className: React.PropTypes.string, // className for the outer element | |
filterOption: React.PropTypes.func, // method to filter a single option: function(option, filterString) | |
filterOptions: React.PropTypes.func, // method to filter the options array: function([options], filterString, [values]) | |
matchPos: React.PropTypes.string, // (any|start) match the start or entire string when filtering | |
matchProp: React.PropTypes.string, // (any|label|value) which option property to filter on | |
inputProps: React.PropTypes.object, // custom attributes for the Input (in the Select-control) e.g: {'data-foo': 'bar'} | |
theme: React.PropTypes.object, // custom theme obj with class names to override default | |
/* | |
* Allow user to make option label clickable. When this handler is defined we should | |
* wrap label into <a>label</a> tag. | |
* | |
* onOptionLabelClick handler: function (value, event) {} | |
* | |
*/ | |
onOptionLabelClick: React.PropTypes.func | |
}, | |
getDefaultProps: function() { | |
return { | |
value: undefined, | |
options: [], | |
disabled: false, | |
delimiter: ',', | |
asyncOptions: undefined, | |
autoload: true, | |
placeholder: 'Select...', | |
noResultsText: 'No results found', | |
clearable: true, | |
clearValueText: 'Clear value', | |
clearAllText: 'Clear all', | |
searchable: true, | |
searchPromptText: 'Type to search', | |
name: undefined, | |
onChange: undefined, | |
className: undefined, | |
matchPos: 'any', | |
matchProp: 'any', | |
inputProps: {}, | |
onOptionLabelClick: undefined | |
}; | |
}, | |
getInitialState: function() { | |
return { | |
/* | |
* set by getStateFromValue on componentWillMount: | |
* - value | |
* - values | |
* - filteredOptions | |
* - inputValue | |
* - placeholder | |
* - focusedOption | |
*/ | |
options: this.props.options, | |
isFocused: false, | |
isOpen: false, | |
isLoading: false, | |
theme: this.props.theme ? _.mapValues(DEFAULT_CLASSES, function(value, key) { | |
return this.props.theme[key] ? value + ' ' + this.props.theme[key] : value; | |
}.bind(this)) : DEFAULT_CLASSES | |
}; | |
}, | |
componentWillMount: function() { | |
this._optionsCache = {}; | |
this._optionsFilterString = ''; | |
this.setState(this.getStateFromValue(this.props.value)); | |
if (this.props.asyncOptions && this.props.autoload) { | |
this.autoloadAsyncOptions(); | |
} | |
this._closeMenuIfClickedOutside = function(event) { | |
var menuElem = this.refs.selectMenuContainer.getDOMNode(); | |
var controlElem = this.refs.control.getDOMNode(); | |
var eventOccuredOutsideMenu = this.clickedOutsideElement(menuElem, event); | |
var eventOccuredOutsideControl = this.clickedOutsideElement(controlElem, event); | |
// Hide dropdown menu if click occurred outside of menu | |
if(eventOccuredOutsideMenu && eventOccuredOutsideControl) { | |
this.setState({ | |
isOpen: false | |
}, this._unbindCloseMenuIfClickedOutside); | |
} | |
}.bind(this); | |
this._bindCloseMenuIfClickedOutside = function() { | |
document.addEventListener('click', this._closeMenuIfClickedOutside); | |
}; | |
this._unbindCloseMenuIfClickedOutside = function() { | |
document.removeEventListener('click', this._closeMenuIfClickedOutside); | |
}; | |
}, | |
componentWillUnmount: function() { | |
clearTimeout(this._blurTimeout); | |
clearTimeout(this._focusTimeout); | |
if(this.state.isOpen) { | |
this._unbindCloseMenuIfClickedOutside(); | |
} | |
}, | |
componentWillReceiveProps: function(newProps) { | |
if (newProps.value !== this.state.value) { | |
this.setState(this.getStateFromValue(newProps.value, newProps.options)); | |
} | |
if (JSON.stringify(newProps.options) !== JSON.stringify(this.props.options)) { | |
this.setState({ | |
options: newProps.options, | |
filteredOptions: this.filterOptions(newProps.options) | |
}); | |
} | |
}, | |
componentDidUpdate: function() { | |
if (this._focusAfterUpdate) { | |
clearTimeout(this._blurTimeout); | |
this._focusTimeout = setTimeout(function() { | |
this.getInputNode().focus(); | |
this._focusAfterUpdate = false; | |
}.bind(this), 50); | |
} | |
if (this._focusedOptionReveal) { | |
if (this.refs.focused && this.refs.menu) { | |
var focusedDOM = this.refs.focused.getDOMNode(); | |
var menuDOM = this.refs.menu.getDOMNode(); | |
var focusedRect = focusedDOM.getBoundingClientRect(); | |
var menuRect = menuDOM.getBoundingClientRect(); | |
if (focusedRect.bottom > menuRect.bottom || | |
focusedRect.top < menuRect.top) { | |
menuDOM.scrollTop = (focusedDOM.offsetTop + focusedDOM.clientHeight - menuDOM.offsetHeight); | |
} | |
} | |
this._focusedOptionReveal = false; | |
} | |
}, | |
clickedOutsideElement: function(element, event) { | |
var eventTarget = (event.target) ? event.target : event.srcElement; | |
while (eventTarget != null) { | |
if (eventTarget === element) return false; | |
eventTarget = eventTarget.offsetParent; | |
} | |
return true; | |
}, | |
getStateFromValue: function(value, options) { | |
if (!options) { | |
options = this.state.options; | |
} | |
// reset internal filter string | |
this._optionsFilterString = ''; | |
var values = this.initValuesArray(value, options), | |
filteredOptions = this.filterOptions(options, values); | |
return { | |
value: values.map(function(v) { return v.value; }).join(this.props.delimiter), | |
values: values, | |
inputValue: '', | |
filteredOptions: filteredOptions, | |
placeholder: !this.props.multi && values.length ? values[0].label : this.props.placeholder, | |
focusedOption: !this.props.multi && values.length ? values[0] : filteredOptions[0] | |
}; | |
}, | |
initValuesArray: function(values, options) { | |
if (!Array.isArray(values)) { | |
if (typeof values === 'string') { | |
values = values.split(this.props.delimiter); | |
} else { | |
values = values ? [values] : []; | |
} | |
} | |
return values.map(function(val) { | |
return (typeof val === 'string') ? val = _.findWhere(options, { value: val }) || { value: val, label: val } : val; | |
}); | |
}, | |
setValue: function(value) { | |
this._focusAfterUpdate = true; | |
var newState = this.getStateFromValue(value); | |
newState.isOpen = false; | |
this.fireChangeEvent(newState); | |
this.setState(newState); | |
}, | |
selectValue: function(value) { | |
if (!this.props.multi) { | |
this.setValue(value); | |
} else if (value) { | |
this.addValue(value); | |
} | |
this._unbindCloseMenuIfClickedOutside(); | |
}, | |
addValue: function(value) { | |
this.setValue(this.state.values.concat(value)); | |
}, | |
popValue: function() { | |
this.setValue(_.initial(this.state.values)); | |
}, | |
removeValue: function(value) { | |
this.setValue(_.without(this.state.values, value)); | |
}, | |
clearValue: function(event) { | |
// if the event was triggered by a mousedown and not the primary | |
// button, ignore it. | |
if (event && event.type === 'mousedown' && event.button !== 0) { | |
return; | |
} | |
this.setValue(null); | |
}, | |
resetValue: function() { | |
this.setValue(this.state.value); | |
}, | |
getInputNode: function () { | |
var input = this.refs.input; | |
return this.props.searchable ? input : input.getDOMNode(); | |
}, | |
fireChangeEvent: function(newState) { | |
if (newState.value !== this.state.value && this.props.onChange) { | |
this.props.onChange(newState.value, newState.values); | |
} | |
}, | |
handleMouseDown: function(event) { | |
// if the event was triggered by a mousedown and not the primary | |
// button, or if the component is disabled, ignore it. | |
if (this.props.disabled || (event.type === 'mousedown' && event.button !== 0)) { | |
return; | |
} | |
event.stopPropagation(); | |
event.preventDefault(); | |
if (this.state.isFocused) { | |
this.setState({ | |
isOpen: true | |
}, this._bindCloseMenuIfClickedOutside); | |
} else { | |
this._openAfterFocus = true; | |
this.getInputNode().focus(); | |
} | |
}, | |
handleInputFocus: function(event) { | |
var newIsOpen = this.state.isOpen || this._openAfterFocus; | |
this.setState({ | |
isFocused: true, | |
isOpen: newIsOpen | |
}, function() { | |
if(newIsOpen) { | |
this._bindCloseMenuIfClickedOutside(); | |
} | |
else { | |
this._unbindCloseMenuIfClickedOutside(); | |
} | |
}); | |
this._openAfterFocus = false; | |
if (this.props.onFocus) { | |
this.props.onFocus(event); | |
} | |
}, | |
handleInputBlur: function(event) { | |
this._blurTimeout = setTimeout(function() { | |
if (this._focusAfterUpdate) return; | |
this.setState({ | |
isFocused: false | |
}); | |
}.bind(this), 50); | |
if (this.props.onBlur) { | |
this.props.onBlur(event); | |
} | |
}, | |
handleKeyDown: function(event) { | |
if (this.state.disabled) return; | |
switch (event.keyCode) { | |
case 8: // backspace | |
if (!this.state.inputValue) { | |
this.popValue(); | |
} | |
return; | |
case 9: // tab | |
if (event.shiftKey || !this.state.isOpen || !this.state.focusedOption) { | |
return; | |
} | |
this.selectFocusedOption(); | |
break; | |
case 13: // enter | |
this.selectFocusedOption(); | |
break; | |
case 27: // escape | |
if (this.state.isOpen) { | |
this.resetValue(); | |
} else { | |
this.clearValue(); | |
} | |
break; | |
case 38: // up | |
this.focusPreviousOption(); | |
break; | |
case 40: // down | |
this.focusNextOption(); | |
break; | |
default: return; | |
} | |
event.preventDefault(); | |
}, | |
handleInputChange: function(event) { | |
// assign an internal variable because we need to use | |
// the latest value before setState() has completed. | |
this._optionsFilterString = event.target.value; | |
if (this.props.asyncOptions) { | |
this.setState({ | |
isLoading: true, | |
inputValue: event.target.value | |
}); | |
this.loadAsyncOptions(event.target.value, { | |
isLoading: false, | |
isOpen: true | |
}, this._bindCloseMenuIfClickedOutside); | |
} else { | |
var filteredOptions = this.filterOptions(this.state.options); | |
this.setState({ | |
isOpen: true, | |
inputValue: event.target.value, | |
filteredOptions: filteredOptions, | |
focusedOption: _.contains(filteredOptions, this.state.focusedOption) ? this.state.focusedOption : filteredOptions[0] | |
}, this._bindCloseMenuIfClickedOutside); | |
} | |
}, | |
autoloadAsyncOptions: function() { | |
this.loadAsyncOptions('', {}, function() {}); | |
}, | |
loadAsyncOptions: function(input, state) { | |
var thisRequestId = this._currentRequestId = requestId++; | |
for (var i = 0; i <= input.length; i++) { | |
var cacheKey = input.slice(0, i); | |
if (this._optionsCache[cacheKey] && (input === cacheKey || this._optionsCache[cacheKey].complete)) { | |
var options = this._optionsCache[cacheKey].options; | |
var filteredOptions = this.filterOptions(options); | |
this.setState(_.extend({ | |
options: options, | |
filteredOptions: filteredOptions, | |
focusedOption: _.contains(filteredOptions, this.state.focusedOption) ? this.state.focusedOption : filteredOptions[0] | |
}, state)); | |
return; | |
} | |
} | |
this.props.asyncOptions(input, function(err, data) { | |
if (err) throw err; | |
this._optionsCache[input] = data; | |
if (thisRequestId !== this._currentRequestId) { | |
return; | |
} | |
var filteredOptions = this.filterOptions(data.options); | |
this.setState(_.extend({ | |
options: data.options, | |
filteredOptions: filteredOptions, | |
focusedOption: _.contains(filteredOptions, this.state.focusedOption) ? this.state.focusedOption : filteredOptions[0] | |
}, state)); | |
}.bind(this)); | |
}, | |
filterOptions: function(options, values) { | |
if (!this.props.searchable) { | |
return options; | |
} | |
var filterValue = this._optionsFilterString; | |
var exclude = (values || this.state.values).map(function(i) { | |
return i.value; | |
}); | |
if (this.props.filterOptions) { | |
return this.props.filterOptions.call(this, options, filterValue, exclude); | |
} else { | |
var filterOption = function(op) { | |
if (this.props.multi && _.contains(exclude, op.value)) return false; | |
if (this.props.filterOption) return this.props.filterOption.call(this, op, filterValue); | |
var valueTest = String(op.value), labelTest = String(op.label); | |
return !filterValue || (this.props.matchPos === 'start') ? ( | |
(this.props.matchProp !== 'label' && valueTest.toLowerCase().substr(0, filterValue.length) === filterValue) || | |
(this.props.matchProp !== 'value' && labelTest.toLowerCase().substr(0, filterValue.length) === filterValue) | |
) : ( | |
(this.props.matchProp !== 'label' && valueTest.toLowerCase().indexOf(filterValue.toLowerCase()) >= 0) || | |
(this.props.matchProp !== 'value' && labelTest.toLowerCase().indexOf(filterValue.toLowerCase()) >= 0) | |
); | |
}; | |
return _.filter(options, filterOption, this); | |
} | |
}, | |
selectFocusedOption: function() { | |
return this.selectValue(this.state.focusedOption); | |
}, | |
focusOption: function(op) { | |
this.setState({ | |
focusedOption: op | |
}); | |
}, | |
focusNextOption: function() { | |
this.focusAdjacentOption('next'); | |
}, | |
focusPreviousOption: function() { | |
this.focusAdjacentOption('previous'); | |
}, | |
focusAdjacentOption: function(dir) { | |
this._focusedOptionReveal = true; | |
var ops = this.state.filteredOptions; | |
if (!this.state.isOpen) { | |
this.setState({ | |
isOpen: true, | |
inputValue: '', | |
focusedOption: this.state.focusedOption || ops[dir === 'next' ? 0 : ops.length - 1] | |
}, this._bindCloseMenuIfClickedOutside); | |
return; | |
} | |
if (!ops.length) { | |
return; | |
} | |
var focusedIndex = -1; | |
for (var i = 0; i < ops.length; i++) { | |
if (this.state.focusedOption === ops[i]) { | |
focusedIndex = i; | |
break; | |
} | |
} | |
var focusedOption = ops[0]; | |
if (dir === 'next' && focusedIndex > -1 && focusedIndex < ops.length - 1) { | |
focusedOption = ops[focusedIndex + 1]; | |
} else if (dir === 'previous') { | |
if (focusedIndex > 0) { | |
focusedOption = ops[focusedIndex - 1]; | |
} else { | |
focusedOption = ops[ops.length - 1]; | |
} | |
} | |
this.setState({ | |
focusedOption: focusedOption | |
}); | |
}, | |
unfocusOption: function(op) { | |
if (this.state.focusedOption === op) { | |
this.setState({ | |
focusedOption: null | |
}); | |
} | |
}, | |
buildMenu: function() { | |
var focusedValue = this.state.focusedOption ? this.state.focusedOption.value : null; | |
if(this.state.filteredOptions.length > 0) { | |
focusedValue = focusedValue == null ? this.state.filteredOptions[0] : focusedValue; | |
} | |
var ops = _.map(this.state.filteredOptions, function(op) { | |
var isFocused = focusedValue === op.value; | |
var optionClass = classes( | |
this.state.theme.SelectOption, | |
isFocused ? this.state.theme.isFocused : null | |
); | |
var ref = isFocused ? 'focused' : null; | |
var mouseEnter = this.focusOption.bind(this, op), | |
mouseLeave = this.unfocusOption.bind(this, op), | |
mouseDown = this.selectValue.bind(this, op); | |
return <div ref={ref} key={'option-' + op.value} className={optionClass} onMouseEnter={mouseEnter} onMouseLeave={mouseLeave} onMouseDown={mouseDown} onClick={mouseDown}>{op.label}</div>; | |
}, this); | |
return ops.length ? ops : ( | |
<div className={this.state.theme.SelectNoresults}> | |
{this.props.asyncOptions && !this.state.inputValue ? this.props.searchPromptText : this.props.noResultsText} | |
</div> | |
); | |
}, | |
handleOptionLabelClick: function (value, event) { | |
var handler = this.props.onOptionLabelClick; | |
if (handler) { | |
handler(value, event); | |
} | |
}, | |
render: function() { | |
var selectClass = classes( | |
this.state.theme.Select, | |
this.props.className, | |
this.props.multi ? this.state.theme.isMulti : null, | |
this.props.searchable ? this.state.theme.isSearchable : null, | |
this.props.isOpen ? this.state.theme.isOpen : null, | |
this.props.isFocused ? this.state.theme.isFocused : null, | |
this.props.isLoading ? this.state.theme.isLoading : null, | |
this.props.disabled ? this.state.theme.isDisabled : null, | |
this.props.value ? this.state.theme.hasValue : null | |
); | |
var value = []; | |
if (this.props.multi) { | |
this.state.values.forEach(function(val) { | |
var props = _.extend({ | |
key: val.value, | |
optionLabelClick: !!this.props.onOptionLabelClick, | |
onOptionLabelClick: this.handleOptionLabelClick.bind(this, val), | |
onRemove: this.removeValue.bind(this, val), | |
theme: this.state.theme | |
}, val); | |
value.push(<Value {...props} />); | |
}, this); | |
} | |
if (this.props.disabled || (!this.state.inputValue && (!this.props.multi || !value.length))) { | |
value.push(<div className={this.state.theme.SelectPlaceholder} key="placeholder">{this.state.placeholder}</div>); | |
} | |
var loading = this.state.isLoading ? <span className={this.state.theme.SelectLoading} aria-hidden="true" /> : null; | |
var clear = this.props.clearable && this.state.value && !this.props.disabled ? <span className={this.state.theme.SelectClear} title={this.props.multi ? this.props.clearAllText : this.props.clearValueText} aria-label={this.props.multi ? this.props.clearAllText : this.props.clearValueText} onMouseDown={this.clearValue} onClick={this.clearValue} dangerouslySetInnerHTML={{ __html: '×' }} /> : null; | |
var menu; | |
var menuProps; | |
if (this.state.isOpen) { | |
menuProps = { | |
ref: 'menu', | |
className: this.state.theme.SelectMenu | |
}; | |
if (this.props.multi) { | |
menuProps.onMouseDown = this.handleMouseDown; | |
} | |
menu = ( | |
<div ref="selectMenuContainer" className={this.state.theme.SelectMenuOuter}> | |
<div {...menuProps}>{this.buildMenu()}</div> | |
</div> | |
); | |
} | |
var input; | |
var inputProps = _.extend({ | |
ref: 'input', | |
className: this.state.theme.SelectInput, | |
tabIndex: this.props.tabIndex || 0, | |
onFocus: this.handleInputFocus, | |
onBlur: this.handleInputBlur | |
}, this.props.inputProps); | |
if (this.props.searchable && !this.props.disabled) { | |
input = <Input value={this.state.inputValue} onChange={this.handleInputChange} minWidth="5" {...inputProps} />; | |
} else { | |
input = <div {...inputProps}> </div>; | |
} | |
return ( | |
<div ref="wrapper" className={selectClass}> | |
<input type="hidden" ref="value" name={this.props.name} value={this.state.value} disabled={this.props.disabled} /> | |
<div className={this.state.theme.SelectControl} ref="control" onKeyDown={this.handleKeyDown} onMouseDown={this.handleMouseDown} onTouchEnd={this.handleMouseDown}> | |
{value} | |
{input} | |
<span className={this.state.theme.SelectArrow} /> | |
{loading} | |
{clear} | |
</div> | |
{menu} | |
</div> | |
); | |
} | |
}); | |
module.exports = Select; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment