UNPKG

selector2

Version:

Virtual selector component for react.js

768 lines (646 loc) 19.5 kB
/** * 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) }>&times;</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) }>&times;</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;