Skip to content

Instantly share code, notes, and snippets.

@piecyk
Last active August 29, 2015 14:17
Show Gist options
  • Save piecyk/14cfd932aabd1989ff10 to your computer and use it in GitHub Desktop.
Save piecyk/14cfd932aabd1989ff10 to your computer and use it in GitHub Desktop.
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: '&times;' }} /> : 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}>&nbsp;</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