multi-select
Version:
A multi-select component for CanJS
385 lines (359 loc) • 11.3 kB
JavaScript
import can from 'can';
import 'can/map/define/';
import 'can-control-processor-capture';
import './styles.less!';
import template from './template.stache!';
export const VM = can.Map.extend({
define: {
// API:
/**
* Option to turn on "Select All" checkbox.
*/
selectAll: {
value: false,
set(val){
if (val === '' || val === 'true' || val === true){
return true;
}
if (val === 'default'){
return 'default';
}
return false;
}
},
/**
* Option to provide a text of "Select All" checkbox.
*/
selectAllText: {
value: 'Select All'
},
/**
* Option to provide a text for label when all items are selected. By default,
* if only a single value is available in the list, when it's checked it will
* show that value in the multi-select's main container. If you pass a value
* in the , it will show that value.
*/
allSelectedText: {
get(lastSetVal){
let list = this.attr('_list');
if (list && list.attr('length') === 1 && !lastSetVal) {
return this.attr('_list.0.text');
}
return lastSetVal || 'All Selected';
}
},
/**
* Option to provide a value for _selectedValues_ when _areAllSelected_ is true.
* In this case the option with this value will be filtered out from the _list_,
* and this value wrapped in an array will be returned.
* ```
* <multi-select select-all all-selected-value="-1" {items}="items" {^selected-values}="selectedValues"></multi-select>
*
* vm.attr('selectedValues') will return [-1] in case all options are selected.
* ```
*/
allSelectedValue: {
value: null
},
/**
* Option to provide a property name where value should be retrieved from.
*/
valueProp: {
value: 'value'
},
/**
* Option to provide a property name where text should be retrieved from.
*/
textProp: {
value: 'text'
},
/**
* Option to provide a property name where isSelected should be defined off.
*/
selectedProp: {
value: 'isSelected'
},
/**
* Option to provide a property name where isDisabled should be defined off.
*/
disabledProp: {
value: 'isDisabled'
},
/**
* Option to provide item click event name. This will be fired on the viewModel or for can-2.2 on both viewModel and element.
*/
clickEventName: {
value: 'itemclick'
},
areAllSelected: {
get(){
return this.attr('_list.length') === this.attr('selected.length');
},
set(val){
if (!this.attr('_list.length')){
return val;
}
can.batch.start();
this.attr('_list').each(item => {
if (!item.attr('isDisabled')){
item.attr('isSelected', val);
}
});
can.batch.stop();
return val;
}
},
/**
* Source list of items for select options passed from parent context.
*/
list: {
value: []
},
/**
* Internal list of items for select options
*/
_list: {
value: []
},
/**
* List contains selected items of this._list
* @return {can.List} List of selected items.
*/
selected: {
get(){
return this.attr('_list').filter(item => item.attr('isSelected'));
}
},
/**
* Will return [<allSelectedValue>] if _all-selected-value_ is specified.
* @return {array} Array of selected values.
*/
selectedValues: {
get: function(){
var prevValues = this.prevValues;
var selectedValues = [].map.call(this.attr('selected'), item => item.attr('value'));
if (this.attr('areAllSelected') && this.attr('allSelectedValue') !== null){
selectedValues = [this.attr('allSelectedValue')];
}
if (prevValues && deepEqual(prevValues, selectedValues)) {
return prevValues;
}
this.prevValues = selectedValues;
return selectedValues;
}
},
/**
* @return {array} Array of selected items (original from list if passed, or the same as _selected_.
*/
selectedItems: {
get(){
return [].map.call(this.attr('selected'), item => {
return item.attr('_item') || item;
});
}
},
/**
* Flag to show/hide list of items
*/
isOpened: {
type: 'boolean',
value: false
},
/**
* MutationObserver to updated _list on new items rendered in content.
*/
observer: {
type: '*'
}
},
select(item){
item.attr('isSelected', !item.attr('isSelected'));
var data = [{
value: item.attr('value'),
isSelected: item.attr('isSelected'),
selectedValues: this.attr('selectedValues')
}];
var eventName = this.attr('clickEventName');
if (this.dispatch){
// for can-2.3 and newer:
this.dispatch(eventName, data);
} else {
// for older can versions:
// trigger on the viewModel:
can.event.dispatch.call(this, eventName, data);
// trigger DOM event on the element to be captured on the parent component with "events: {'multi-select itemclick': function(){} }":
this.el.trigger(eventName, data);
}
},
toggle(){
this.attr('isOpened', !this.attr('isOpened'));
},
close(){
this.attr('isOpened', false);
},
/**
* Main init function for internal _list.
* @param {can.List} items
*/
initList(items){
var mappedItems;
// filter out allSelectedValue:
if (this.attr('allSelectedValue') !== null){
var allSelectedValue = this.attr('allSelectedValue');
items = items.filter(item => item.value !== allSelectedValue);
}
// If no template content with <option> tags then get items from list:
if (!items || !items.length){
items = mapItems(this.attr('list'), this.attr('valueProp'), this.attr('textProp'), this.attr('selectedProp'), this.attr('disabledProp'));
}
// Preselect all:
if (this.attr('selectAll') === 'default'){
mappedItems = items.map(item => { return item.isSelected = true, item; });
} else {
mappedItems = items;
}
this.attr('_list').replace(mappedItems);
},
addItem(item){
this.attr('_list').push(item);
},
removeItem(item){
var pos = [].reduce.call(this.attr('_list'), function(acc, _item, i){
return _item.value === item.value ? i : acc;
}, -1);
this.attr('_list').splice(pos, 1);
},
moreThanOneItem(){
let list = this.attr('_list');
return list && list.length > 1;
}
});
export default can.Component.extend({
tag: 'multi-select',
template: template,
viewModel: VM,
events: {
inserted(el, ev){
var self = this;
this.viewModel.el = el;
this.viewModel.initList(getItems(el.find('option')));
var target = el.find('.orig-options')[0];
// Observe changes of the DOM option list:
var observer = new MutationObserver(function(mutations) {
//console.log('MutationObserver! mutations: ', mutations);
mutations.forEach(function(mutation) {
switch(mutation.type){
case 'childList':
getItems(mutation.addedNodes).forEach(option => self.viewModel.addItem(option));
getItems(mutation.removedNodes).forEach(option => self.viewModel.removeItem(option));
break;
case 'attributes':
var attrToProp = {
selected: 'isSelected',
disabled: 'isDisabled'
};
var itemValue = mutation.target.value,
attrName = mutation.attributeName,
propName = attrToProp[attrName],
attrValue = mutation.target.getAttribute(attrName);
if (propName){
var item = getItemByValue(self.viewModel.attr('_list'), itemValue);
item.attr(propName, (attrValue === null ? false : true));
//console.log('- attribute for the item %s: %s=%s %s', itemValue, attrName, attrValue, item.attr(propName));
}
break;
}
});
});
// configuration of the observer:
var config = {
childList: true,
attributes: true,
subtree: true
};
// pass in the target node, as well as the observer options
observer.observe(target, config);
this.viewModel.attr('observer', observer);
},
/**
* Destroy the Mutation Observer when this component is torn down.
*/
removed(){
//stop observing
this.viewModel.attr('observer').disconnect();
},
'{document} click capture': function(el, ev){
if($(this.element).has(ev.target).length === 0){
this.viewModel.close();
}
}
}
});
/**
* Turns a nodeList list of OPTION elements into an array of data.
* @param {[type]} nodeList The node list containing the options.
* @return {[type]} An array representing the original OPTION elements.
*/
export function getItems(nodeList){
return makeArr(nodeList)
.filter(node => node.nodeName === "OPTION")
.map(option => getItemFromOption(option));
}
/**
* Makes an object for internal list out of OPTION DOM element.
* @param {DOMNode} el
* @returns {{value: *, text: *, isSelected: *}}
*/
export function getItemFromOption(el){
var $el = $(el);
return {
value: $el.val(),
text: $el.text(),
isSelected: $el.is(':selected'),
isDisabled: $el.is(':disabled')
};
}
/**
* Makes array from array-like structure and returns it.
* @param arrayLike
* @returns {Array.<T>}
*/
export function makeArr(arrayLike){
return [].slice.call(arrayLike);
}
/**
* Maps value, text, and isSelected to attributes that exist on the provided list of data.
* @param {[type]} list The multi-select list.
* @param {[type]} valProp The property where the value resides in each list item.
* @param {[type]} textProp The property where the text / label resides in each list item.
* @param {[type]} selectedProp The property where the isChecked/Boolean resides in each list item.
* @return {[type]} An array of objects that contain value, text, and isSelected from
* the original list.
*/
export function mapItems(list, valProp, textProp, selectedProp, disabledProp){
if (!list || !list.length){
return [];
}
return [].map.call(list, function(item, n){
if (item[valProp] === undefined || item[valProp] === null){
console.warn('A ' + valProp + ' property is undefined/null at index ' + n + '.');
}
return {
value: item[valProp],
text: item[textProp],
isSelected: !!item[selectedProp],
isDisabled: !!item[disabledProp],
_item: item
};
});
}
export function deepEqual(listA, listB){
return listA.length === listB.length && listA.reduce(function(acc, a){
return acc && listB.indexOf(a) !== -1;
}, true);
}
export function getItemByValue(list, value){
return Array.prototype.reduce.call(list, function(acc, item){
return acc || item.attr('value') === value && item;
}, false);
}