/** * 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 = ''; var DROPDOWN_CONTAINER = ''; var SEARCH_BOX = ''; var DROPDOWN_MENU = ''; var NO_SEARCH_RESULT = '
  • NO SEARCH RESULT
  • '; var ACTIONS_BOX = '
    ' + '
    ' + '' + '' + '
    ' + '
    '; 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); } }; } } }]); })();