eventman/static/js/nya-bs-select.js
2016-06-18 20:01:17 +02:00

1723 lines
No EOL
57 KiB
JavaScript

/**
* nya-bootstrap-select v2.1.6
* Copyright 2014 Nyasoft
* Licensed under MIT license
*/
(function(){
'use strict';
var uid = 0;
function nextUid() {
return ++uid;
}
/**
* Checks if `obj` is a window object.
*
* @private
* @param {*} obj Object to check
* @returns {boolean} True if `obj` is a window obj.
*/
function isWindow(obj) {
return obj && obj.window === obj;
}
/**
* @ngdoc function
* @name angular.isString
* @module ng
* @kind function
*
* @description
* Determines if a reference is a `String`.
*
* @param {*} value Reference to check.
* @returns {boolean} True if `value` is a `String`.
*/
function isString(value){return typeof value === 'string';}
/**
* @param {*} obj
* @return {boolean} Returns true if `obj` is an array or array-like object (NodeList, Arguments,
* String ...)
*/
function isArrayLike(obj) {
if (obj == null || isWindow(obj)) {
return false;
}
var length = obj.length;
if (obj.nodeType === 1 && length) {
return true;
}
return isString(obj) || Array.isArray(obj) || length === 0 ||
typeof length === 'number' && length > 0 && (length - 1) in obj;
}
/**
* Creates a new object without a prototype. This object is useful for lookup without having to
* guard against prototypically inherited properties via hasOwnProperty.
*
* Related micro-benchmarks:
* - http://jsperf.com/object-create2
* - http://jsperf.com/proto-map-lookup/2
* - http://jsperf.com/for-in-vs-object-keys2
*
* @returns {Object}
*/
function createMap() {
return Object.create(null);
}
/**
* Computes a hash of an 'obj'.
* Hash of a:
* string is string
* number is number as string
* object is either result of calling $$hashKey function on the object or uniquely generated id,
* that is also assigned to the $$hashKey property of the object.
*
* @param obj
* @returns {string} hash string such that the same input will have the same hash string.
* The resulting string key is in 'type:hashKey' format.
*/
function hashKey(obj, nextUidFn) {
var objType = typeof obj,
key;
if (objType == 'function' || (objType == 'object' && obj !== null)) {
if (typeof (key = obj.$$hashKey) == 'function') {
// must invoke on object to keep the right this
key = obj.$$hashKey();
} else if (key === undefined) {
key = obj.$$hashKey = (nextUidFn || nextUid)();
}
} else {
key = obj;
}
return objType + ':' + key;
}
//TODO: use with caution. if an property of element in array doesn't exist in group, the resultArray may lose some element.
function sortByGroup(array ,group, property) {
var unknownGroup = [],
i, j,
resultArray = [];
for(i = 0; i < group.length; i++) {
for(j = 0; j < array.length;j ++) {
if(!array[j][property]) {
unknownGroup.push(array[j]);
} else if(array[j][property] === group[i]) {
resultArray.push(array[j]);
}
}
}
resultArray = resultArray.concat(unknownGroup);
return resultArray;
}
/**
* Return the DOM siblings between the first and last node in the given array.
* @param {Array} array like object
* @returns {jqLite} jqLite collection containing the nodes
*/
function getBlockNodes(nodes) {
// TODO(perf): just check if all items in `nodes` are siblings and if they are return the original
// collection, otherwise update the original collection.
var node = nodes[0];
var endNode = nodes[nodes.length - 1];
var blockNodes = [node];
do {
node = node.nextSibling;
if (!node) break;
blockNodes.push(node);
} while (node !== endNode);
return angular.element(blockNodes);
}
var getBlockStart = function(block) {
return block.clone[0];
};
var getBlockEnd = function(block) {
return block.clone[block.clone.length - 1];
};
var updateScope = function(scope, index, valueIdentifier, value, keyIdentifier, key, arrayLength, group) {
// TODO(perf): generate setters to shave off ~40ms or 1-1.5%
scope[valueIdentifier] = value;
if (keyIdentifier) scope[keyIdentifier] = key;
scope.$index = index;
scope.$first = (index === 0);
scope.$last = (index === (arrayLength - 1));
scope.$middle = !(scope.$first || scope.$last);
// jshint bitwise: false
scope.$odd = !(scope.$even = (index&1) === 0);
// jshint bitwise: true
if(group) {
scope.$group = group;
}
};
var setElementIsolateScope = function(element, scope) {
element.data('isolateScope', scope);
};
var contains = function(array, element) {
var length = array.length,
i;
if(length === 0) {
return false;
}
for(i = 0;i < length; i++) {
if(deepEquals(element, array[i])) {
return true;
}
}
return false;
};
var indexOf = function(array, element) {
var length = array.length,
i;
if(length === 0) {
return -1;
}
for(i = 0; i < length; i++) {
if(deepEquals(element, array[i])) {
return i;
}
}
return -1;
};
/**
* filter the event target for the nya-bs-option element.
* Use this method with event delegate. (attach a event handler on an parent element and listen the special children elements)
* @param target event.target node
* @param parent {object} the parent, where the event handler attached.
* @param selector {string}|{object} a class or DOM element
* @return the filtered target or null if no element satisfied the selector.
*/
var filterTarget = function(target, parent, selector) {
var elem = target,
className, type = typeof selector;
if(target == parent) {
return null;
} else {
do {
if(type === 'string') {
className = ' ' + elem.className + ' ';
if(elem.nodeType === 1 && className.replace(/[\t\r\n\f]/g, ' ').indexOf(selector) >= 0) {
return elem;
}
} else {
if(elem == selector) {
return elem;
}
}
} while((elem = elem.parentNode) && elem != parent && elem.nodeType !== 9);
return null;
}
};
var getClassList = function(element) {
var classList,
className = element.className.replace(/[\t\r\n\f]/g, ' ').trim();
classList = className.split(' ');
for(var i = 0; i < classList.length; i++) {
if(/\s+/.test(classList[i])) {
classList.splice(i, 1);
i--;
}
}
return classList;
};
// work with node element
var hasClass = function(element, className) {
var classList = getClassList(element);
return classList.indexOf(className) !== -1;
};
// query children by class(one or more)
var queryChildren = function(element, classList) {
var children = element.children(),
length = children.length,
child,
valid,
classes;
if(length > 0) {
for(var i = 0; i < length; i++) {
child = children.eq(i);
valid = true;
classes = getClassList(child[0]);
if(classes.length > 0) {
for(var j = 0; j < classList.length; j++) {
if(classes.indexOf(classList[j]) === -1) {
valid = false;
break;
}
}
}
if(valid) {
return child;
}
}
}
return [];
};
/**
* Current support only drill down one level.
* case insensitive
* @param element
* @param keyword
*/
var hasKeyword = function(element, keyword) {
var childElements,
index, length;
if(element.text().toLowerCase().indexOf(keyword.toLowerCase()) !== -1) {
return true;
} else {
childElements = element.children();
length = childElements.length;
for(index = 0; index < length; index++) {
if(childElements.eq(index).text().toLowerCase().indexOf(keyword.toLowerCase()) !== -1) {
return true;
}
}
return false;
}
};
function sibling( cur, dir ) {
while ( (cur = cur[dir]) && cur.nodeType !== 1) {}
return cur;
}
// map global property to local variable.
var jqLite = angular.element;
var deepEquals = angular.equals;
var deepCopy = angular.copy;
var extend = angular.extend;
var nyaBsSelect = angular.module('nya.bootstrap.select', []);
/**
* A service for configuration. the configuration is shared globally.
* Testing ci build --jpmckearin
*/
nyaBsSelect.provider('nyaBsConfig', function() {
var locale = null;
// default localized text. cannot be modified.
var defaultText = {
'en-us': {
defaultNoneSelection: 'Nothing selected',
noSearchResult: 'NO SEARCH RESULT',
numberItemSelected: '%d items selected',
selectAll: 'Select All',
deselectAll: 'Deselect All'
}
};
// localized text which actually being used.
var interfaceText = deepCopy(defaultText);
/**
* Merge with default localized text.
* @param localeId a string formatted as languageId-countryId
* @param obj localized text object.
*/
this.setLocalizedText = function(localeId, obj) {
if(!localeId) {
throw new Error('localeId must be a string formatted as languageId-countryId');
}
if(!interfaceText[localeId]) {
interfaceText[localeId] = {};
}
interfaceText[localeId] = extend(interfaceText[localeId], obj);
};
/**
* Force to use a special locale id. if localeId is null. reset to user-agent locale.
* @param localeId a string formatted as languageId-countryId
*/
this.useLocale = function(localeId) {
locale = localeId;
};
/**
* get the localized text according current locale or forced locale
* @returns localizedText
*/
this.$get = ['$locale', function($locale){
var localizedText;
if(locale) {
localizedText = interfaceText[locale];
} else {
localizedText = interfaceText[$locale.id];
}
if(!localizedText) {
localizedText = defaultText['en-us'];
}
return localizedText;
}];
});
nyaBsSelect.controller('nyaBsSelectCtrl', function(){
var self = this;
// keyIdentifier and valueIdentifier are set by nyaBsOption directive
// used by nyaBsSelect directive to retrieve key and value from each nyaBsOption's child scope.
self.keyIdentifier = null;
self.valueIdentifier = null;
self.isMultiple = false;
// Should be override by nyaBsSelect directive and called by nyaBsOption directive when collection is changed.
self.onCollectionChange = function(){};
// for debug
self.setId = function(id) {
self.id = id || 'id#' + Math.floor(Math.random() * 10000);
};
});
nyaBsSelect.directive('nyaBsSelect', ['$parse', '$document', '$timeout', '$compile', 'nyaBsConfig', function ($parse, $document, $timeout, $compile, nyaBsConfig) {
var DEFAULT_NONE_SELECTION = 'Nothing selected';
var DROPDOWN_TOGGLE = '<button class="btn btn-default dropdown-toggle" type="button">' +
'<span class="pull-left filter-option"></span>' +
'<span class="pull-left special-title"></span>' +
'&nbsp;' +
'<span class="caret"></span>' +
'</button>';
var DROPDOWN_CONTAINER = '<div class="dropdown-menu open"></div>';
var SEARCH_BOX = '<div class="bs-searchbox">' +
'<input type="text" class="form-control">' +
'</div>';
var DROPDOWN_MENU = '<ul class="dropdown-menu inner"></ul>';
var NO_SEARCH_RESULT = '<li class="no-search-result"><span>NO SEARCH RESULT</span></li>';
var ACTIONS_BOX = '<div class="bs-actionsbox">' +
'<div class="btn-group btn-group-sm btn-block">' +
'<button class="actions-btn bs-select-all btn btn-default">SELECT ALL</button>' +
'<button class="actions-btn bs-deselect-all btn btn-default">DESELECT ALL</button>' +
'</div>' +
'</div>';
return {
restrict: 'ECA',
require: ['ngModel', 'nyaBsSelect'],
controller: 'nyaBsSelectCtrl',
compile: function nyaBsSelectCompile (tElement, tAttrs){
tElement.addClass('btn-group');
/**
* get the default text when nothing is selected. can be template
* @param scope, if provided, will try to compile template with given scope, will not attempt to compile the pure text.
* @returns {*}
*/
var getDefaultNoneSelectionContent = function(scope) {
// text node or jqLite element.
var content;
if(tAttrs.titleTpl) {
// use title-tpl attribute value.
content = jqLite(tAttrs.titleTpl);
} else if(tAttrs.title) {
// use title attribute value.
content = document.createTextNode(tAttrs.title);
} else if(localizedText.defaultNoneSelectionTpl){
// use localized text template.
content = jqLite(localizedText.defaultNoneSelectionTpl);
} else if(localizedText.defaultNoneSelection) {
// use localized text.
content = document.createTextNode(localizedText.defaultNoneSelection);
} else {
// use default.
content = document.createTextNode(DEFAULT_NONE_SELECTION);
}
if(scope && (tAttrs.titleTpl || localizedText.defaultNoneSelectionTpl)) {
return $compile(content)(scope);
}
return content;
};
var options = tElement.children(),
dropdownToggle = jqLite(DROPDOWN_TOGGLE),
dropdownContainer = jqLite(DROPDOWN_CONTAINER),
dropdownMenu = jqLite(DROPDOWN_MENU),
searchBox,
noSearchResult,
actionsBox,
classList,
length,
index,
liElement,
localizedText = nyaBsConfig,
isMultiple = typeof tAttrs.multiple !== 'undefined',
nyaBsOptionValue;
classList = getClassList(tElement[0]);
classList.forEach(function(className) {
if(/btn-(?:primary|info|success|warning|danger|inverse)/.test(className)) {
tElement.removeClass(className);
dropdownToggle.removeClass('btn-default');
dropdownToggle.addClass(className);
}
if(/btn-(?:lg|sm|xs)/.test(className)) {
tElement.removeClass(className);
dropdownToggle.addClass(className);
}
if(className === 'form-control') {
dropdownToggle.addClass(className);
}
});
dropdownMenu.append(options);
// add tabindex to children anchor elements if not present.
// tabindex attribute will give an anchor element ability to be get focused.
length = options.length;
for(index = 0; index < length; index++) {
liElement = options.eq(index);
if(liElement.hasClass('nya-bs-option') || liElement.attr('nya-bs-option')) {
liElement.find('a').attr('tabindex', '0');
// In order to be compatible with old version, we should copy value of value attribute into data-value attribute.
// For the reason we use data-value instead, see http://nya.io/AngularJS/Beware-Of-Using-value-Attribute-On-list-element/
nyaBsOptionValue = liElement.attr('value');
if(angular.isString(nyaBsOptionValue) && nyaBsOptionValue !== '') {
liElement.attr('data-value', nyaBsOptionValue);
liElement.removeAttr('value');
}
}
}
if(tAttrs.liveSearch === 'true') {
searchBox = jqLite(SEARCH_BOX);
if(tAttrs.noSearchTitle) {
NO_SEARCH_RESULT = NO_SEARCH_RESULT.replace('NO SEARCH RESULT', tAttrs.noSearchTitle);
} else if (tAttrs.noSearchTitleTpl) {
NO_SEARCH_RESULT = NO_SEARCH_RESULT.replace('NO SEARCH RESULT', tAttrs.noSearchTitleTpl);
}else {
// set localized text
if(localizedText.noSearchResultTpl) {
NO_SEARCH_RESULT = NO_SEARCH_RESULT.replace('NO SEARCH RESULT', localizedText.noSearchResultTpl);
} else if(localizedText.noSearchResult) {
NO_SEARCH_RESULT = NO_SEARCH_RESULT.replace('NO SEARCH RESULT', localizedText.noSearchResult);
}
}
noSearchResult = jqLite(NO_SEARCH_RESULT);
dropdownContainer.append(searchBox);
dropdownMenu.append(noSearchResult);
}
if (tAttrs.actionsBox === 'true' && isMultiple) {
// set localizedText
if (localizedText.selectAllTpl) {
ACTIONS_BOX = ACTIONS_BOX.replace('SELECT ALL', localizedText.selectAllTpl);
} else if (localizedText.selectAll) {
ACTIONS_BOX = ACTIONS_BOX.replace('SELECT ALL', localizedText.selectAll);
}
if (localizedText.deselectAllTpl) {
ACTIONS_BOX = ACTIONS_BOX.replace('DESELECT ALL', localizedText.deselectAllTpl);
} else if (localizedText.selectAll) {
ACTIONS_BOX = ACTIONS_BOX.replace('DESELECT ALL', localizedText.deselectAll);
}
actionsBox = jqLite(ACTIONS_BOX);
dropdownContainer.append(actionsBox);
}
// set default none selection text
jqLite(dropdownToggle[0].querySelector('.special-title')).append(getDefaultNoneSelectionContent());
dropdownContainer.append(dropdownMenu);
tElement.append(dropdownToggle);
tElement.append(dropdownContainer);
return function nyaBsSelectLink ($scope, $element, $attrs, ctrls) {
var ngCtrl = ctrls[0],
nyaBsSelectCtrl = ctrls[1],
liHeight,
isDisabled = false,
previousTabIndex,
valueExpFn,
valueExpGetter = $parse(nyaBsSelectCtrl.valueExp),
isMultiple = typeof $attrs.multiple !== 'undefined';
// find element from current $element root. because the compiled element may be detached from DOM tree by ng-if or ng-switch.
var dropdownToggle = jqLite($element[0].querySelector('.dropdown-toggle')),
dropdownContainer = dropdownToggle.next(),
dropdownMenu = jqLite(dropdownContainer[0].querySelector('.dropdown-menu.inner')),
searchBox = jqLite(dropdownContainer[0].querySelector('.bs-searchbox')),
noSearchResult = jqLite(dropdownMenu[0].querySelector('.no-search-result')),
actionsBox = jqLite(dropdownContainer[0].querySelector('.bs-actionsbox'));
if(nyaBsSelectCtrl.valueExp) {
valueExpFn = function(scope, locals) {
return valueExpGetter(scope, locals);
};
}
// for debug
nyaBsSelectCtrl.setId($element.attr('id'));
if (isMultiple) {
nyaBsSelectCtrl.isMultiple = true;
// required validator
ngCtrl.$isEmpty = function(value) {
return !value || value.length === 0;
};
}
if(typeof $attrs.disabled !== 'undefined') {
$scope.$watch($attrs.disabled, function(disabled){
if(disabled) {
dropdownToggle.addClass('disabled');
dropdownToggle.attr('disabled', 'disabled');
previousTabIndex = dropdownToggle.attr('tabindex');
dropdownToggle.attr('tabindex', '-1');
isDisabled = true;
} else {
dropdownToggle.removeClass('disabled');
dropdownToggle.removeAttr('disabled');
if(previousTabIndex) {
dropdownToggle.attr('tabindex', previousTabIndex);
} else {
dropdownToggle.removeAttr('tabindex');
}
isDisabled = false;
}
});
}
/**
* Do some check on modelValue. remove no existing value
* @param values
* @param deepWatched
*/
nyaBsSelectCtrl.onCollectionChange = function (values, deepWatched) {
var valuesForSelect = [],
index,
modelValueChanged = false,
// Due to ngModelController compare reference with the old modelValue, we must set an new array instead of modifying the old one.
// See: https://github.com/angular/angular.js/issues/1751
modelValue = deepCopy(ngCtrl.$modelValue);
if(!modelValue) {
return;
}
/**
* Behavior change, since 2.1.0, we don't want to reset model to null or empty array when options' collection is not prepared.
*/
if(Array.isArray(values) && values.length > 0) {
if(valueExpFn) {
for(index = 0; index < values.length; index++) {
valuesForSelect.push(valueExpFn($scope, values[index]));
}
} else {
for(index = 0; index < values.length; index++) {
if(nyaBsSelectCtrl.valueIdentifier) {
valuesForSelect.push(values[index][nyaBsSelectCtrl.valueIdentifier]);
} else if(nyaBsSelectCtrl.keyIdentifier) {
valuesForSelect.push(values[index][nyaBsSelectCtrl.keyIdentifier]);
}
}
}
if(isMultiple) {
for(index = 0; index < modelValue.length; index++) {
if(!contains(valuesForSelect, modelValue[index])) {
modelValueChanged = true;
modelValue.splice(index, 1);
index--;
}
}
if(modelValueChanged) {
// modelValue changed.
ngCtrl.$setViewValue(modelValue);
updateButtonContent();
}
} else {
if(!contains(valuesForSelect, modelValue)) {
modelValue = valuesForSelect[0];
ngCtrl.$setViewValue(modelValue);
updateButtonContent();
}
}
}
/**
* if we set deep-watch="true" on nyaBsOption directive,
* we need to refresh dropdown button content whenever a change happened in collection.
*/
if(deepWatched) {
updateButtonContent();
}
};
// view --> model
dropdownMenu.on('click', function menuEventHandler (event) {
if(isDisabled) {
return;
}
if(jqLite(event.target).hasClass('dropdown-header')) {
return;
}
var nyaBsOptionNode = filterTarget(event.target, dropdownMenu[0], 'nya-bs-option'),
nyaBsOption;
if(nyaBsOptionNode !== null) {
nyaBsOption = jqLite(nyaBsOptionNode);
if(nyaBsOption.hasClass('disabled')) {
return;
}
selectOption(nyaBsOption);
}
});
// if click the outside of dropdown menu, close the dropdown menu
var outClick = function(event) {
if(filterTarget(event.target, $element.parent()[0], $element[0]) === null) {
if($element.hasClass('open')) {
$element.triggerHandler('blur');
}
$element.removeClass('open');
}
};
$document.on('click', outClick);
dropdownToggle.on('blur', function() {
if(!$element.hasClass('open')) {
$element.triggerHandler('blur');
}
});
dropdownToggle.on('click', function() {
var nyaBsOptionNode;
$element.toggleClass('open');
if($element.hasClass('open') && typeof liHeight === 'undefined') {
calcMenuSize();
}
if($attrs.liveSearch === 'true' && $element.hasClass('open')) {
searchBox.children().eq(0)[0].focus();
nyaBsOptionNode = findFocus(true);
if(nyaBsOptionNode) {
dropdownMenu.children().removeClass('active');
jqLite(nyaBsOptionNode).addClass('active');
}
} else if($element.hasClass('open')) {
nyaBsOptionNode = findFocus(true);
if(nyaBsOptionNode) {
setFocus(nyaBsOptionNode);
}
}
});
// actions box
if ($attrs.actionsBox === 'true' && isMultiple) {
actionsBox.find('button').eq(0).on('click', function () {
setAllOptions(true);
});
actionsBox.find('button').eq(1).on('click', function () {
setAllOptions(false);
});
}
// live search
if($attrs.liveSearch === 'true') {
searchBox.children().on('input', function(){
var searchKeyword = searchBox.children().val(),
found = 0,
options = dropdownMenu.children(),
length = options.length,
index,
option,
nyaBsOptionNode;
if(searchKeyword) {
for(index = 0; index < length; index++) {
option = options.eq(index);
if(option.hasClass('nya-bs-option')) {
if(!hasKeyword(option.find('a'), searchKeyword)) {
option.addClass('not-match');
} else {
option.removeClass('not-match');
found++;
}
}
}
if(found === 0) {
noSearchResult.addClass('show');
} else {
noSearchResult.removeClass('show');
}
} else {
for(index = 0; index < length; index++) {
option = options.eq(index);
if(option.hasClass('nya-bs-option')) {
option.removeClass('not-match');
}
}
noSearchResult.removeClass('show');
}
nyaBsOptionNode = findFocus(true);
if(nyaBsOptionNode) {
options.removeClass('active');
jqLite(nyaBsOptionNode).addClass('active');
}
});
}
// model --> view
ngCtrl.$render = function() {
var modelValue = ngCtrl.$modelValue,
index,
bsOptionElements = dropdownMenu.children(),
length = bsOptionElements.length,
value;
if(typeof modelValue === 'undefined') {
// if modelValue is undefined. uncheck all option
for(index = 0; index < length; index++) {
if(bsOptionElements.eq(index).hasClass('nya-bs-option')) {
bsOptionElements.eq(index).removeClass('selected');
}
}
} else {
for(index = 0; index < length; index++) {
if(bsOptionElements.eq(index).hasClass('nya-bs-option')) {
value = getOptionValue(bsOptionElements.eq(index));
if(isMultiple) {
if(contains(modelValue, value)) {
bsOptionElements.eq(index).addClass('selected');
} else {
bsOptionElements.eq(index).removeClass('selected');
}
} else {
if(deepEquals(modelValue, value)) {
bsOptionElements.eq(index).addClass('selected');
} else {
bsOptionElements.eq(index).removeClass('selected');
}
}
}
}
}
//console.log(nyaBsSelectCtrl.id + ' render end');
updateButtonContent();
};
// simple keyboard support
$element.on('keydown', function(event){
var keyCode = event.keyCode;
if(keyCode !== 27 && keyCode !== 13 && keyCode !== 38 && keyCode !== 40) {
// we only handle special keys. don't waste time to traverse the dom tree.
return;
}
// prevent a click event to be fired.
event.preventDefault();
if(isDisabled) {
event.stopPropagation();
return;
}
var toggleButton = filterTarget(event.target, $element[0], dropdownToggle[0]),
menuContainer,
searchBoxContainer,
liElement,
nyaBsOptionNode;
if($attrs.liveSearch === 'true') {
searchBoxContainer = filterTarget(event.target, $element[0], searchBox[0]);
} else {
menuContainer = filterTarget(event.target, $element[0], dropdownContainer[0])
}
if(toggleButton) {
// press enter to active dropdown
if((keyCode === 13 || keyCode === 38 || keyCode === 40) && !$element.hasClass('open')) {
event.stopPropagation();
$element.addClass('open');
// calculate menu size
if(typeof liHeight === 'undefined') {
calcMenuSize();
}
// if live search enabled. give focus to search box.
if($attrs.liveSearch === 'true') {
searchBox.children().eq(0)[0].focus();
// find the focusable node but we will use active
nyaBsOptionNode = findFocus(true);
if(nyaBsOptionNode) {
// remove previous active state
dropdownMenu.children().removeClass('active');
// set active to first focusable element
jqLite(nyaBsOptionNode).addClass('active');
}
} else {
// otherwise, give focus to first menu item.
nyaBsOptionNode = findFocus(true);
if(nyaBsOptionNode) {
setFocus(nyaBsOptionNode);
}
}
}
// press enter or escape to de-active dropdown
//if((keyCode === 13 || keyCode === 27) && $element.hasClass('open')) {
// $element.removeClass('open');
// event.stopPropagation();
//}
} else if(menuContainer) {
if(keyCode === 27) {
// escape pressed
dropdownToggle[0].focus();
if($element.hasClass('open')) {
$element.triggerHandler('blur');
}
$element.removeClass('open');
event.stopPropagation();
} else if(keyCode === 38) {
event.stopPropagation();
// up arrow key
nyaBsOptionNode = findNextFocus(event.target.parentNode, 'previousSibling');
if(nyaBsOptionNode) {
setFocus(nyaBsOptionNode);
} else {
nyaBsOptionNode = findFocus(false);
if(nyaBsOptionNode) {
setFocus(nyaBsOptionNode);
}
}
} else if(keyCode === 40) {
event.stopPropagation();
// down arrow key
nyaBsOptionNode = findNextFocus(event.target.parentNode, 'nextSibling');
if(nyaBsOptionNode) {
setFocus(nyaBsOptionNode);
} else {
nyaBsOptionNode = findFocus(true);
if(nyaBsOptionNode) {
setFocus(nyaBsOptionNode);
}
}
} else if(keyCode === 13) {
event.stopPropagation();
// enter pressed
liElement = jqLite(event.target.parentNode);
if(liElement.hasClass('nya-bs-option')) {
selectOption(liElement);
if(!isMultiple) {
dropdownToggle[0].focus();
}
}
}
} else if(searchBoxContainer) {
if(keyCode === 27) {
dropdownToggle[0].focus();
$element.removeClass('open');
event.stopPropagation();
} else if(keyCode === 38) {
// up
event.stopPropagation();
liElement = findActive();
if(liElement) {
nyaBsOptionNode = findNextFocus(liElement[0], 'previousSibling');
if(nyaBsOptionNode) {
liElement.removeClass('active');
jqLite(nyaBsOptionNode).addClass('active');
} else {
nyaBsOptionNode = findFocus(false);
if(nyaBsOptionNode) {
liElement.removeClass('active');
jqLite(nyaBsOptionNode).addClass('active');
}
}
}
} else if(keyCode === 40) {
// down
event.stopPropagation();
liElement = findActive();
if(liElement) {
nyaBsOptionNode = findNextFocus(liElement[0], 'nextSibling');
if(nyaBsOptionNode) {
liElement.removeClass('active');
jqLite(nyaBsOptionNode).addClass('active');
} else {
nyaBsOptionNode = findFocus(true);
if(nyaBsOptionNode) {
liElement.removeClass('active');
jqLite(nyaBsOptionNode).addClass('active');
}
}
}
} else if(keyCode === 13) {
// select an option.
liElement = findActive();
if(liElement) {
selectOption(liElement);
if(!isMultiple) {
dropdownToggle[0].focus();
}
}
}
}
});
function findActive() {
var list = dropdownMenu.children(),
i, liElement,
length = list.length;
for(i = 0; i < length; i++) {
liElement = list.eq(i);
if(liElement.hasClass('active') && liElement.hasClass('nya-bs-option') && !liElement.hasClass('not-match')) {
return liElement;
}
}
return null;
}
/**
* setFocus on a nya-bs-option element. it actually set focus on its child anchor element.
* @param elem a nya-bs-option element.
*/
function setFocus(elem) {
var childList = elem.childNodes,
length = childList.length,
child;
for(var i = 0; i < length; i++) {
child = childList[i];
if(child.nodeType === 1 && child.tagName.toLowerCase() === 'a') {
child.focus();
break;
}
}
}
function findFocus(fromFirst) {
var firstLiElement;
if(fromFirst) {
firstLiElement = dropdownMenu.children().eq(0);
} else {
firstLiElement = dropdownMenu.children().eq(dropdownMenu.children().length - 1);
}
// focus on selected element
for(var i = 0; i < dropdownMenu.children().length; i++) {
var childElement = dropdownMenu.children().eq(i);
if (!childElement.hasClass('not-match') && childElement.hasClass('selected')) {
return dropdownMenu.children().eq(i)[0];
}
}
if(firstLiElement.hasClass('nya-bs-option') && !firstLiElement.hasClass('disabled') && !firstLiElement.hasClass('not-match')) {
return firstLiElement[0];
} else {
if(fromFirst) {
return findNextFocus(firstLiElement[0], 'nextSibling');
} else {
return findNextFocus(firstLiElement[0], 'previousSibling');
}
}
}
/**
* find next focusable element on direction
* @param from the element traversed from
* @param direction can be 'nextSibling' or 'previousSibling'
* @returns the element if found, otherwise return null.
*/
function findNextFocus(from, direction) {
if(from && !hasClass(from, 'nya-bs-option')) {
return;
}
var next = from;
while ((next = sibling(next, direction)) && next.nodeType) {
if(hasClass(next,'nya-bs-option') && !hasClass(next, 'disabled') && !hasClass(next, 'not-match')) {
return next
}
}
return null;
}
/**
*
*/
function setAllOptions(selectAll) {
if (!isMultiple || isDisabled)
return;
var liElements,
wv,
viewValue;
liElements = dropdownMenu[0].querySelectorAll('.nya-bs-option');
if (liElements.length > 0) {
wv = ngCtrl.$viewValue;
// make a deep copy enforce ngModelController to call its $render method.
// See: https://github.com/angular/angular.js/issues/1751
viewValue = Array.isArray(wv) ? deepCopy(wv) : [];
for (var i = 0; i < liElements.length; i++) {
var nyaBsOption = jqLite(liElements[i]);
if (nyaBsOption.hasClass('disabled'))
continue;
var value, index;
// if user specify the value attribute. we should use the value attribute
// otherwise, use the valueIdentifier specified field in target scope
value = getOptionValue(nyaBsOption);
if (typeof value !== 'undefined') {
index = indexOf(viewValue, value);
if (selectAll && index == -1) {
// check element
viewValue.push(value);
nyaBsOption.addClass('selected');
} else if (!selectAll && index != -1) {
// uncheck element
viewValue.splice(index, 1);
nyaBsOption.removeClass('selected');
}
}
}
// update view value regardless
ngCtrl.$setViewValue(viewValue);
$scope.$digest();
updateButtonContent();
}
}
/**
* select an option represented by nyaBsOption argument. Get the option's value and update model.
* if isMultiple = true, doesn't close dropdown menu. otherwise close the menu.
* @param nyaBsOption the jqLite wrapped `nya-bs-option` element.
*/
function selectOption(nyaBsOption) {
var value,
viewValue,
wv = ngCtrl.$viewValue,
index;
// if user specify the value attribute. we should use the value attribute
// otherwise, use the valueIdentifier specified field in target scope
value = getOptionValue(nyaBsOption);
if(typeof value !== 'undefined') {
if(isMultiple) {
// make a deep copy enforce ngModelController to call its $render method.
// See: https://github.com/angular/angular.js/issues/1751
viewValue = Array.isArray(wv) ? deepCopy(wv) : [];
index = indexOf(viewValue, value);
if(index === -1) {
// check element
viewValue.push(value);
nyaBsOption.addClass('selected');
} else {
// uncheck element
viewValue.splice(index, 1);
nyaBsOption.removeClass('selected');
}
} else {
dropdownMenu.children().removeClass('selected');
viewValue = value;
nyaBsOption.addClass('selected');
}
}
// update view value regardless
ngCtrl.$setViewValue(viewValue);
$scope.$digest();
if(!isMultiple) {
// in single selection mode. close the dropdown menu
if($element.hasClass('open')) {
$element.triggerHandler('blur');
}
$element.removeClass('open');
dropdownToggle[0].focus();
}
updateButtonContent();
}
/**
* get a value of current nyaBsOption. according to different setting.
* - if `nya-bs-option` directive is used to populate options and a `value` attribute is specified. use expression of the attribute value.
* - if `nya-bs-option` directive is used to populate options and no other settings, use the valueIdentifier or keyIdentifier to retrieve value from scope of current nyaBsOption.
* - if `nya-bs-option` class is used on static options. use literal value of the `value` attribute.
* @param nyaBsOption a jqLite wrapped `nya-bs-option` element
*/
function getOptionValue(nyaBsOption) {
var scopeOfOption;
if(valueExpFn) {
// here we use the scope bound by ourselves in the nya-bs-option.
scopeOfOption = nyaBsOption.data('isolateScope');
return valueExpFn(scopeOfOption);
} else {
if(nyaBsSelectCtrl.valueIdentifier || nyaBsSelectCtrl.keyIdentifier) {
scopeOfOption = nyaBsOption.data('isolateScope');
return scopeOfOption[nyaBsSelectCtrl.valueIdentifier] || scopeOfOption[nyaBsSelectCtrl.keyIdentifier];
} else {
return nyaBsOption.attr('data-value');
}
}
}
function getOptionText(nyaBsOption) {
var item = nyaBsOption.find('a');
if(item.children().length === 0 || item.children().eq(0).hasClass('check-mark')) {
// if the first child is check-mark or has no children, means the option text is text node
return item[0].firstChild.cloneNode(false);
} else {
// otherwise we clone the first element of the item
return item.children().eq(0)[0].cloneNode(true);
}
}
function updateButtonContent() {
var viewValue = ngCtrl.$viewValue;
$element.triggerHandler('change');
var filterOption = jqLite(dropdownToggle[0].querySelector('.filter-option'));
var specialTitle = jqLite(dropdownToggle[0].querySelector('.special-title'));
if(typeof viewValue === 'undefined') {
/**
* Select empty option when model is undefined.
*/
dropdownToggle.addClass('show-special-title');
filterOption.empty();
return;
}
if(isMultiple && viewValue.length === 0) {
dropdownToggle.addClass('show-special-title');
filterOption.empty();
} else {
dropdownToggle.removeClass('show-special-title');
$timeout(function() {
var bsOptionElements = dropdownMenu.children(),
value,
nyaBsOption,
index,
length = bsOptionElements.length,
optionTitle,
selection = [],
match,
count;
if(isMultiple && $attrs.selectedTextFormat === 'count') {
count = 1;
} else if(isMultiple && $attrs.selectedTextFormat && (match = $attrs.selectedTextFormat.match(/\s*count\s*>\s*(\d+)\s*/))) {
count = parseInt(match[1], 10);
}
// data-selected-text-format="count" or data-selected-text-format="count>x"
if((typeof count !== 'undefined') && viewValue.length > count) {
filterOption.empty();
if(localizedText.numberItemSelectedTpl) {
filterOption.append(jqLite(localizedText.numberItemSelectedTpl.replace('%d', viewValue.length)));
} else if(localizedText.numberItemSelected) {
filterOption.append(document.createTextNode(localizedText.numberItemSelected.replace('%d', viewValue.length)));
} else {
filterOption.append(document.createTextNode(viewValue.length + ' items selected'));
}
return;
}
// data-selected-text-format="values" or the number of selected items is less than count
for(index = 0; index < length; index++) {
nyaBsOption = bsOptionElements.eq(index);
if(nyaBsOption.hasClass('nya-bs-option')) {
value = getOptionValue(nyaBsOption);
if(isMultiple) {
if(Array.isArray(viewValue) && contains(viewValue, value)) {
// if option has an title attribute. use the title value as content show in button.
// otherwise get very first child element.
optionTitle = nyaBsOption.attr('title');
if(optionTitle) {
selection.push(document.createTextNode(optionTitle));
} else {
selection.push(getOptionText(nyaBsOption));
}
}
} else {
if(deepEquals(viewValue, value)) {
optionTitle = nyaBsOption.attr('title');
if(optionTitle) {
selection.push(document.createTextNode(optionTitle));
} else {
selection.push(getOptionText(nyaBsOption));
}
}
}
}
}
if(selection.length === 0) {
filterOption.empty();
dropdownToggle.addClass('show-special-title');
} else if(selection.length === 1) {
dropdownToggle.removeClass('show-special-title');
// either single or multiple selection will show the only selected content.
filterOption.empty();
filterOption.append(selection[0]);
} else {
dropdownToggle.removeClass('show-special-title');
filterOption.empty();
for(index = 0; index < selection.length; index++) {
filterOption.append(selection[index]);
if(index < selection.length -1) {
filterOption.append(document.createTextNode(', '));
}
}
}
});
}
}
// will called only once.
function calcMenuSize(){
var liElements = dropdownMenu.find('li'),
length = liElements.length,
liElement,
i;
for(i = 0; i < length; i++) {
liElement = liElements.eq(i);
if(liElement.hasClass('nya-bs-option') || liElement.attr('nya-bs-option')) {
liHeight = liElement[0].clientHeight;
break;
}
}
if(/\d+/.test($attrs.size)) {
var dropdownSize = parseInt($attrs.size, 10);
dropdownMenu.css('max-height', (dropdownSize * liHeight) + 'px');
dropdownMenu.css('overflow-y', 'auto');
}
}
$scope.$on('$destroy', function() {
dropdownMenu.off();
dropdownToggle.off();
if (searchBox.off) searchBox.off();
$document.off('click', outClick);
});
};
}
};
}]);
nyaBsSelect.directive('nyaBsOption', ['$parse', function($parse){
//00000011111111111111100000000022222222222222200000003333333333333330000000000000004444444444000000000000000000055555555550000000000000000000006666666666000000
var BS_OPTION_REGEX = /^\s*(?:([\$\w][\$\w]*)|(?:\(\s*([\$\w][\$\w]*)\s*,\s*([\$\w][\$\w]*)\s*\)))\s+in\s+([\s\S]+?)(?:\s+group\s+by\s+([\s\S]+?))?(?:\s+track\s+by\s+([\s\S]+?))?\s*$/;
return {
restrict: 'A',
transclude: 'element',
priority: 1000,
terminal: true,
require: ['^nyaBsSelect', '^ngModel'],
compile: function nyaBsOptionCompile (tElement, tAttrs) {
var expression = tAttrs.nyaBsOption;
var nyaBsOptionEndComment = document.createComment(' end nyaBsOption: ' + expression + ' ');
var match = expression.match(BS_OPTION_REGEX);
if(!match) {
throw new Error('invalid expression');
}
// we want to keep our expression comprehensible so we don't use 'select as label for value in collection' expression.
var valueExp = tAttrs.value,
valueExpGetter = valueExp ? $parse(valueExp) : null;
var valueIdentifier = match[3] || match[1],
keyIdentifier = match[2],
collectionExp = match[4],
groupByExpGetter = match[5] ? $parse(match[5]) : null,
trackByExp = match[6];
var trackByIdArrayFn,
trackByIdObjFn,
trackByIdExpFn,
trackByExpGetter;
var hashFnLocals = {$id: hashKey};
var groupByFn, locals = {};
if(trackByExp) {
trackByExpGetter = $parse(trackByExp);
} else {
trackByIdArrayFn = function(key, value) {
return hashKey(value);
};
trackByIdObjFn = function(key) {
return key;
};
}
return function nyaBsOptionLink($scope, $element, $attr, ctrls, $transclude) {
var nyaBsSelectCtrl = ctrls[0],
ngCtrl = ctrls[1],
valueExpFn,
deepWatched,
valueExpLocals = {};
if(trackByExpGetter) {
trackByIdExpFn = function(key, value, index) {
// assign key, value, and $index to the locals so that they can be used in hash functions
if (keyIdentifier) {
hashFnLocals[keyIdentifier] = key;
}
hashFnLocals[valueIdentifier] = value;
hashFnLocals.$index = index;
return trackByExpGetter($scope, hashFnLocals);
};
}
if(groupByExpGetter) {
groupByFn = function(key, value) {
if(keyIdentifier) {
locals[keyIdentifier] = key;
}
locals[valueIdentifier] = value;
return groupByExpGetter($scope, locals);
}
}
// set keyIdentifier and valueIdentifier property of nyaBsSelectCtrl
if(keyIdentifier) {
nyaBsSelectCtrl.keyIdentifier = keyIdentifier;
}
if(valueIdentifier) {
nyaBsSelectCtrl.valueIdentifier = valueIdentifier;
}
if(valueExpGetter) {
nyaBsSelectCtrl.valueExp = valueExp;
valueExpFn = function(key, value) {
if(keyIdentifier) {
valueExpLocals[keyIdentifier] = key;
}
valueExpLocals[valueIdentifier] = value;
return valueExpGetter($scope, valueExpLocals);
}
}
// Store a list of elements from previous run. This is a hash where key is the item from the
// iterator, and the value is objects with following properties.
// - scope: bound scope
// - element: previous element.
// - index: position
//
// We are using no-proto object so that we don't need to guard against inherited props via
// hasOwnProperty.
var lastBlockMap = createMap();
// deepWatch will impact performance. use with caution.
if($attr.deepWatch === 'true') {
deepWatched = true;
$scope.$watch(collectionExp, nyaBsOptionAction, true);
} else {
deepWatched = false;
$scope.$watchCollection(collectionExp, nyaBsOptionAction);
}
function nyaBsOptionAction(collection) {
var index,
previousNode = $element[0], // node that cloned nodes should be inserted after
// initialized to the comment node anchor
key, value,
trackById,
trackByIdFn,
collectionKeys,
collectionLength,
// Same as lastBlockMap but it has the current state. It will become the
// lastBlockMap on the next iteration.
nextBlockMap = createMap(),
nextBlockOrder,
block,
groupName,
nextNode,
group,
lastGroup,
removedClone, // removed clone node, should also remove isolateScope data as well
values = [],
valueObj; // the collection value
if(groupByFn) {
group = [];
}
if(isArrayLike(collection)) {
collectionKeys = collection;
trackByIdFn = trackByIdExpFn || trackByIdArrayFn;
} else {
trackByIdFn = trackByIdExpFn || trackByIdObjFn;
// if object, extract keys, sort them and use to determine order of iteration over obj props
collectionKeys = [];
for (var itemKey in collection) {
if (collection.hasOwnProperty(itemKey) && itemKey.charAt(0) != '$') {
collectionKeys.push(itemKey);
}
}
collectionKeys.sort();
}
collectionLength = collectionKeys.length;
nextBlockOrder = new Array(collectionLength);
for(index = 0; index < collectionLength; index++) {
key = (collection === collectionKeys) ? index : collectionKeys[index];
value = collection[key];
trackById = trackByIdFn(key, value, index);
// copy the value with scope like structure to notify the select directive.
valueObj = {};
if(keyIdentifier) {
valueObj[keyIdentifier] = key;
}
valueObj[valueIdentifier] = value;
values.push(valueObj);
if(groupByFn) {
groupName = groupByFn(key, value);
if(group.indexOf(groupName) === -1 && groupName) {
group.push(groupName);
}
}
if(lastBlockMap[trackById]) {
// found previously seen block
block = lastBlockMap[trackById];
delete lastBlockMap[trackById];
// must update block here because some data we stored may change.
if(groupByFn) {
block.group = groupName;
}
block.key = key;
block.value = value;
nextBlockMap[trackById] = block;
nextBlockOrder[index] = block;
} else if(nextBlockMap[trackById]) {
//if collision detected. restore lastBlockMap and throw an error
nextBlockOrder.forEach(function(block) {
if(block && block.scope) {
lastBlockMap[block.id] = block;
}
});
throw new Error("Duplicates in a select are not allowed. Use 'track by' expression to specify unique keys.");
} else {
// new never before seen block
nextBlockOrder[index] = {id: trackById, scope: undefined, clone: undefined, key: key, value: value};
nextBlockMap[trackById] = true;
if(groupName) {
nextBlockOrder[index].group = groupName;
}
}
}
// only resort nextBlockOrder when group found
if(group && group.length > 0) {
nextBlockOrder = sortByGroup(nextBlockOrder, group, 'group');
}
// remove DOM nodes
for( var blockKey in lastBlockMap) {
block = lastBlockMap[blockKey];
removedClone = getBlockNodes(block.clone);
// remove the isolateScope data to detach scope from this clone
removedClone.removeData('isolateScope');
removedClone.remove();
block.scope.$destroy();
}
for(index = 0; index < collectionLength; index++) {
block = nextBlockOrder[index];
if(block.scope) {
// if we have already seen this object, then we need to reuse the
// associated scope/element
nextNode = previousNode;
if(getBlockStart(block) != nextNode) {
jqLite(previousNode).after(block.clone);
}
previousNode = getBlockEnd(block);
updateScope(block.scope, index, valueIdentifier, block.value, keyIdentifier, block.key, collectionLength, block.group);
} else {
$transclude(function nyaBsOptionTransclude(clone, scope) {
// in case of the debugInfoEnable is set to false, we have to bind the scope to the clone node.
setElementIsolateScope(clone, scope);
block.scope = scope;
var endNode = nyaBsOptionEndComment.cloneNode(false);
clone[clone.length++] = endNode;
jqLite(previousNode).after(clone);
// add nya-bs-option class
clone.addClass('nya-bs-option');
// for newly created item we need to ensure its selected status from the model value.
if(valueExpFn) {
value = valueExpFn(block.key, block.value);
} else {
value = block.value || block.key;
}
if(nyaBsSelectCtrl.isMultiple) {
if(Array.isArray(ngCtrl.$modelValue) && contains(ngCtrl.$modelValue, value)) {
clone.addClass('selected');
}
} else {
if(deepEquals(value, ngCtrl.$modelValue)) {
clone.addClass('selected');
}
}
previousNode = endNode;
// Note: We only need the first/last node of the cloned nodes.
// However, we need to keep the reference to the jqlite wrapper as it might be changed later
// by a directive with templateUrl when its template arrives.
block.clone = clone;
nextBlockMap[block.id] = block;
updateScope(block.scope, index, valueIdentifier, block.value, keyIdentifier, block.key, collectionLength, block.group);
});
}
// we need to mark the first item of a group
if(group) {
if(!lastGroup || lastGroup !== block.group) {
block.clone.addClass('first-in-group');
} else {
block.clone.removeClass('first-in-group');
}
lastGroup = block.group;
// add special class for indent
block.clone.addClass('group-item');
}
}
lastBlockMap = nextBlockMap;
nyaBsSelectCtrl.onCollectionChange(values, deepWatched);
}
};
}
}
}]);
})();