selector2
Version:
Virtual selector component for react.js
768 lines (646 loc) • 19.5 kB
JSX
/**
* Virtual selector for react.js
*
* @version 1.1.5
* @author artisan.
* @Date(2015-11-06)
* @example https://code-artisan.github.io/selector2
* @copyright artisan
*/
import _ from 'underscore';
import $ from 'jquery';
import React from 'react';
import ReactDOM from 'react-dom';
import classnames from 'classnames';
import SelectorFilter from './components/SelectorFilter.jsx';
import SelectorDropdown from './components/SelectorDropdown.jsx';
class Selector2 extends React.Component {
constructor(props) {
super(props);
this.state = {
group: false,
loading: false,
options: [],
previous: {}, // Pervious selected options.
// Is opened dropdown.
dropdown: Boolean(props.autoOpen),
// Selected options store.
selected: []
};
/**
* Copy props.options.
*
* @type {Object}
*/
this.store = {
options: []
};
/**
* Cache dropdown element.
*
* @type {Object}
*/
this.$dropdown = null;
/**
* Cache selector container element.
*
* @type {Object}
*/
this.$container = null;
/**
* Component display name in react develop tool.
*
* @type {String}
*/
this.displayName = 'Selector2';
this.handleParentScroll = this.handleParentScroll.bind(this);
this.handleCloseDropdown = this.handleCloseDropdown.bind(this);
}
/**
* Fetch data from remote server.
*
* @param {Object} confingures.
* @return {Undefined}
*/
fetch(props) {
let request = $.getJSON(props.remote.url),
results = [],
selected = []; // Save default selected optioins.
if (!this.state.loading) {
this.setState({
loading: true
});
}
request.then((response) => {
results = props.remote.field ? response[ props.remote.field ] : response;
if ($.isArray(results)) {
results = results.map(option => {
return $.isPlainObject(option) ? option : {label: option, value: option};
});
results = Object.assign({}, props, {options: results});
this.cloneAndFilterOptions(results);
this.setState({
loading : false
});
}
});
}
/**
* Get jquery element by given ref name.
*
* @param {string} name ref name.
* @return {object} element.
*/
getElementByRefName(name) {
let $element = null,
element = this.refs[name];
if ( this.refs && element ) {
if (_.isElement(element)) {
$element = $(this.refs[name]);
} else {
$element = $(element.getDOMNode());
}
}
return $element;
}
/**
* Clone and set unique key.
*
* @param {array} properties.options options
* @return {array} copyed array.
*/
clonePropsOption({options}) {
let resouces = [], increment = 0,
groupOptions;
if (_.isArray(options)) {
resouces = $.extend(true, resouces, options);
resouces.forEach((resouce, unique) => {
groupOptions = resouce.options;
// Support group.
if (_.isArray(groupOptions)) {
this.state.group = true;
groupOptions.forEach((option, second) => {
option.parent = unique;
option.unique = increment++;
});
} else {
// Normal select type.
resouce.unique = unique;
}
});
}
return resouces;
}
/**
* Filter default options.
*
* @param {string|array} options.defaults default options.
* @param {string} options.separator separator.
* @return {array} filter options.
*/
filterDefaultOption({defaults, separator}) {
let temp, values = [], selected = [],
{ options } = this.state;
// Is array. e.g: ['foo', 'bar', ...] or [{...}, {...}] or ['foo', {...}]
if ( _.isArray(defaults) ) {
// If is single mode.
if ( ! this.props.multiple ) {
defaults = [_.last(defaults)];
}
defaults.forEach((option, index) => {
// If option is string.
if ( _.isString(option) ) {
values.push( option.trim() );
// Object...
} else if ( $.isPlainObject(option) ) {
temp = _.pick(option, 'label');
if (_.isString(temp)) {
values.push( temp );
}
}
});
defaults = values.join(separator);
}
// Is string. e.g: 'foo,bar,...'
if ( _.isString(defaults) ) {
defaults.split(separator).forEach((value) => {
if (this.state.group) {
options.forEach((group) => {
temp = _.find(group.options, {
label: value.trim()
});
if ($.isPlainObject(temp)) {
return selected.push(temp);
}
});
} else {
temp = _.find(options, {value: value.trim()});
}
if ($.isPlainObject(temp)) {
selected.push( temp );
}
});
}
selected = _.union(selected);
if (! this.props.nullable &&
selected.length === 0 &&
! this.state.group) {
selected.push(options[0] || '');
}
return selected;
}
/**
* Filter options by keyword.
*
* @param {string} keyword keyword.
* @return {array} options.
*/
filterOptionByKeyword(keyword) {
keyword = $.trim(keyword);
let illegal = /[\^|\$|\.|\*|\+|\-|\?|\=|\!|\:|\||\\|\/|\(|\)|\[|\]|\{|\}]/g;
// Replace illegal chars. e.g: 'hello world.' '$variable'
keyword = keyword.replace(new RegExp(illegal), ($0) => `\\${$0}`);
let matcher = new RegExp(keyword, 'i'),
options = this.clonePropsOption(this.store),
results = [], temp;
// Filter options by keyword.
if (this.state.group) { // Group.
_.forEach(options, (group) => {
temp = _.filter(group.options, (option) => {
return option.label.match(matcher);
});
if (temp.length) {
results.push({
group: group.group,
options: temp
});
}
});
} else {
results = _.filter(options, (option) => {
return option.label.match( matcher );
});
}
this.setState({
options: results
});
}
/**
* Stop propagation.
*
* @param {object} event event.
* @return {undefined}
*/
stopPropagation(event){
event.stopPropagation();
if ( event.nativeEvent ) {
event.nativeEvent.stopImmediatePropagation();
}
}
/**
* Exec callback after selecte some option.
*
* @return {Undefined}
*/
handleAfterSelected() {
let {
selected,
previous
} = this.state, // Cache.
{ onChange } = this.props,
results = {
values: [], // Cache selected options value.
labels: [], // Cache selected options label.
active: null, // Last selected option.
selected: []
};
selected.forEach((option) => {
results.values.push(option.value);
results.labels.push(option.label);
});
results.active = _.last(selected);
results.selected = $.extend(true, results.selected, selected);
if (_.isEqual(results, previous)) return false;
previous = $.extend(true, previous, results);
if (_.isFunction(onChange)) {
onChange(results);
}
}
initialization(props) {
if ($.isPlainObject(props.remote)) {
this.state.loading = true;
return this.fetch(props);
}
this.cloneAndFilterOptions(props);
}
componentWillMount() {
this.initialization(this.props);
}
cloneAndFilterOptions(props) {
this.store.options = this.clonePropsOption(props);
// Copy resouces and set unique key.
this.state.options = this.clonePropsOption(props);
// Find defualt selected options by this.props.defaults field.
this.state.selected = this.filterDefaultOption(props);
}
componentDidMount() {
if (this.props.autoOpen) {
this.handleToggleDropdown();
}
// Scroll handle.
$(this.props.parent).on('scroll', this.handleParentScroll);
// Click handle.
$(document).on('click virtual-selector:undropdown', this.handleCloseDropdown)
.on('virtual-selector:updatedoption', this.handleParentScroll);
}
componentWillUnmount() {
this.handleToggleDropdown(true); // #5
// Remove scroll event.
$(this.props.parent).off('scroll', this.handleParentScroll);
// Remove click event.
$(document).off('click virtual-selector:undropdown', this.handleCloseDropdown)
.off('virtual-selector:updatedoption', this.handleParentScroll);
}
componentWillReceiveProps(props) {
this.state.dropdown = Boolean(props.autoOpen);
if ($.isPlainObject(props.remote)) {
if (!_.isEqual(props.remote, this.props.remote)) {
this.initialization(props);
}
} else {
this.initialization(props);
}
}
componentDidUpdate() {
this.handleToggleDropdown();
this.handleParentScroll();
}
/**
* Parent node scroll event.
*
* @return {undefined}
*/
handleParentScroll() {
if ( this.$dropdown ) {
let offset = this.$container.offset(),
offsetTop = offset.top + this.$container.height(),
dropdownHeight = this.$dropdown.height(),
$window = $(window);
// Fix position and set dropdown class name.
if ($window.scrollTop() + $window.height() - offsetTop < dropdownHeight) {
offsetTop = offset.top - dropdownHeight;
this.$dropdown.removeClass('selector-dropdown-down').addClass('selector-dropdown-up');
this.$container.removeClass('selector-dropdown-down').addClass('selector-dropdown-up');
} else {
this.$dropdown.removeClass('selector-dropdown-up').addClass('selector-dropdown-down');
this.$container.removeClass('selector-dropdown-up').addClass('selector-dropdown-down');
}
// Set dropdown positon.
this.$dropdown.css({'top': offsetTop, 'left': offset.left});
}
}
/**
* Open / close dropdown.
*
* @param {object} event event.
* @return {undefined}
*/
handleOpenDropdown(event) {
this.stopPropagation(event);
if ( this.props.disabled || this.state.loading ) {
return false;
}
let isEqual = _.isEqual(this.state.options, this.props.options);
// Copy options.
if ($.isPlainObject(this.props.remote)) { // Remote.
this.state.options = this.clonePropsOption(this.store);
// Not equal.
} else if (isEqual === false) {
this.state.options = this.clonePropsOption(this.props);
}
let { dropdown } = this.state;
if ( dropdown === false ) {
this.triggerUnDropdown();
}
this.setState({
dropdown: ! dropdown
});
}
/**
* Trigger undropdown.
*
* @return {undefined}
*/
triggerUnDropdown() {
$(document).trigger('virtual-selector:undropdown');
}
/**
* Close dropdown.
*
* @return {undefined}
*/
handleCloseDropdown(event) {
if ( this.state.dropdown ) {
let element = event.target,
contains = $.contains(this.$dropdown[0], element) || $.contains(this.$container[0], element);
if (contains === false) {
this.setState({
dropdown: false
});
}
}
}
/**
* Append option to selected options.
*
* @param {object} option target
* @return {undefined}
*/
handleAppendActiveOption(option) {
if ( $.isPlainObject(option) ) {
let { selected } = this.state,
{ onSelectClose, multiple } = this.props;
if ( _.find(selected, option) && multiple ) {
this.handleRemoveSelectedOption( option );
} else {
// Set selected option.
if ( multiple ) {
selected.push(option);
} else {
selected = [ option ];
}
this.setState({
selected: selected,
dropdown: ! onSelectClose
}, this.handleAfterSelected);
}
}
}
/**
* Clear all selected options.
*
* @param {object} event event.
* @return {undefined}
*/
handleClearSelectedOption(event) {
this.stopPropagation(event);
if ( this.state.dropdown ) {
if ( this.$dropdown !== null ) {
this.$dropdown.css('width', 'auto');
}
}
this.triggerUnDropdown();
this.setState({
selected: [],
dropdown: true
}, this.handleAfterSelected);
}
/**
* Remove option from selected options.
*
* @param {object|number} target target.
* @return {undefined}
*/
handleRemoveSelectedOption(target, dropdown, event) {
let { selected } = this.state,
{ onSelectClose } = this.props;
if ( ! _.isUndefined(event) ) {
this.stopPropagation(event);
}
if ( $.isPlainObject(target) ) {
target = _.findIndex(selected, target);
}
if ( _.isNumber(target) ) {
if ( target >= 0 ) {
selected.splice(target, 1);
this.setState({
selected: selected,
dropdown: dropdown || ! onSelectClose
}, this.handleAfterSelected);
}
}
}
/**
* Toggle dropdown component by state's dropdown.
*
* @return {Undefined}
*/
handleToggleDropdown(unmount = false) {
if ( this.state.dropdown && unmount === false ) {
// Get container dom.
if ( this.$container === null ) {
this.$container = this.getElementByRefName('container');
}
let { searchable, disabled, autoFocus,
theme, className, size } = this.props,
position = {
width: this.$container.outerWidth()
};
if ( this.$dropdown === null ) {
position.height = this.$container.height();
position.offset = this.$container.offset();
this.$dropdown = $(`<div class="${ classnames('selector-container', `selector-${size}`, 'selector-dropdown', `selector-${theme}`, className.dropdown) }"
style="left: ${position.offset.left}px; top: ${position.offset.top + position.height}px;"></div>`);
// Append selector-container to body.
$('body').append( this.$dropdown );
}
// Reset width.
this.$dropdown.css('width', 'auto');
ReactDOM.render(
<SelectorDropdown shortcuts={ this.props.shortcuts } {...this.state} template={ this.props.template.option }
noResultText={ this.props.noResultText } onSelect={ this.handleAppendActiveOption.bind(this) }>
{
searchable && ! disabled ? <SelectorFilter autoFocus={ this.props.autoFocus } onChange={ this.filterOptionByKeyword.bind(this) } /> : null
}
</SelectorDropdown>, this.$dropdown[0],
// Set width.
() => {
let $width = this.$container.outerWidth();
let _width = ~~this.$dropdown.outerWidth();
let finalWidth = _width <= $width ? $width : 'auto';
this.$dropdown.css('width', finalWidth);
});
} else {
if ( this.$dropdown ) {
// Unmount dropdown component.
ReactDOM.unmountComponentAtNode(this.$dropdown[0]);
// Destroy dropdown.
this.$dropdown.remove();
this.$dropdown = null;
}
}
}
/**
* Render loading component.
*
* @return {Object} loading component.
*/
loading(message) {
return (
<div className="selector-loading">{ message }</div>
);
}
/**
* Render renderer component.
*
* @param {Boolean} isEmpty Is empty.
* @param {Boolean} multiple Is multiple.
* @return {Object}
*/
renderer(isEmpty, multiple) {
let { selected } = this.state,
{
clearable, placeholder, disabled, template
} = this.props, templates;
return (
<ul className="selector-renderer">
{ // Display placeholder element if selected option's length equal to 0.
isEmpty ? <span className="selector-placeholder">{ placeholder }</span> : null
}
{ // Map selected options.
selected.map((option, unique) => {
if ( _.isString(template.selected) ) {
templates = _.template(template.selected)(option);
} else {
templates = option.label;
}
return (
<li className="selector-choice" key={ unique }>
{ multiple ? <span className="selector-choice-remove" onClick={ this.handleRemoveSelectedOption.bind(this, option, true) }>×</span> : null }
<span dangerouslySetInnerHTML={{__html: templates}}></span>
</li>
)
})
}
{ // Display clear element if clearable equal to true.
(clearable && ! disabled) && ! isEmpty ? <span className="selector-clearer" onClick={ this.handleClearSelectedOption.bind(this) }>×</span> : null
}
</ul>
);
}
render() {
let { selected, dropdown, loading } = this.state,
{ clearable, multiple, theme,
disabled, remote, size } = this.props,
isEmpty = selected.length === 0;
// Set class name by multiple.
let selectMode = multiple ? 'selector-multiple' : 'selector-single';
return (
<div className={classnames('selector-container', `selector-${theme}`, `selector-${size}`, {
'selector-opened' : dropdown,
'selector-disabled': disabled || this.state.loading
}, this.props.className.container)} onClick={ this.handleOpenDropdown.bind(this) } ref="container">
<div className={classnames('selector-selection', selectMode, {
'selector-clearable': (clearable && ! disabled) && ! isEmpty
})}>
{
loading && $.isPlainObject(remote) ? this.loading(remote.loading) : this.renderer(isEmpty, multiple)
}
</div>
</div>
);
}
}
Selector2.defaultProps = {
autoOpen: false,
theme: 'default',
parent: document,
size: 'md',
remote: null,
options: [],
defaults: [],
nullable: true,
multiple: false,
disabled: false,
onChange: null,
template: {
option: null,
selected: null
},
shortcuts: true,
separator: ',',
autoFocus: true,
clearable: true,
className: {
dropdown: null,
container: null
},
searchable: true,
placeholder: 'Please select...',
noResultText: 'No options to show.',
onSelectClose: true
};
Selector2.propTypes = {
autoOpen: React.PropTypes.bool,
theme: React.PropTypes.string,
parent: React.PropTypes.oneOfType([
React.PropTypes.string,
React.PropTypes.object
]),
size: React.PropTypes.oneOf([
'sm', 'md', 'lg'
]),
options: React.PropTypes.array.isRequired,
defaults: React.PropTypes.oneOfType([
React.PropTypes.array,
React.PropTypes.string,
]),
nullable: React.PropTypes.bool,
multiple: React.PropTypes.bool,
disabled: React.PropTypes.bool,
onChange: React.PropTypes.func,
template: React.PropTypes.object,
shortcuts: React.PropTypes.bool,
separator: React.PropTypes.string,
autoFocus: React.PropTypes.bool,
clearable: React.PropTypes.bool,
className: React.PropTypes.oneOfType([
React.PropTypes.object,
React.PropTypes.string
]),
searchable: React.PropTypes.bool,
placeholder: React.PropTypes.string,
noResultText: React.PropTypes.string,
onSelectClose: React.PropTypes.bool
};
export default Selector2;