/* * angular-ui-bootstrap * http://angular-ui.github.io/bootstrap/ * Version: 0.11.2 - 2014-09-26 * License: MIT * Changes: Added keyboard support for menu */ angular.module('ui.bootstrap.dropdown', []) .constant('dropdownConfig', { openClass: 'open' }) .service('dropdownService', ['$document', function ($document) { var openScope = null; this.open = function (dropdownScope) { if (!openScope) { $document.bind('click', closeDropdown); $document.bind('keydown', onKeydown); } if (openScope && openScope !== dropdownScope) { openScope.isOpen = false; } openScope = dropdownScope; }; this.close = function (dropdownScope) { if (openScope === dropdownScope) { openScope = null; $document.unbind('click', closeDropdown); $document.unbind('keydown', onKeydown); } }; var closeDropdown = function (evt) { var toggleElement = openScope.getToggleElement(); if (evt && toggleElement && toggleElement[0].contains(evt.target)) { return; } openScope.$apply(function () { openScope.isOpen = false; }); }; var onKeydown = function (evt) { if (evt.which === 40) { try { $(evt.target).parent().next().find('[role=menuitem],[role=menuitemcheckbox]')[0].focus(); } catch (ex) { } evt.preventDefault(); evt.stopPropagation(); } else if (evt.which === 38) { try { $(evt.target).parent().prev().find('[role=menuitem],[role=menuitemcheckbox]')[0].focus(); } catch (ex) { } evt.preventDefault(); evt.stopPropagation(); } else if (evt.which === 27) { openScope.focusToggleElement(); closeDropdown(); } else if (evt.which === 13) { var target = evt.target; if (target.getAttribute("role") === "menuitem" || target.getAttribute("role") === "menuitemcheckbox") { setTimeout(function () { try { angular.element(target).trigger('click'); } catch (ex) { } }, 0); } } }; }]) .controller('DropdownController', ['$scope', '$attrs', '$parse', 'dropdownConfig', 'dropdownService', '$animate', function ($scope, $attrs, $parse, dropdownConfig, dropdownService, $animate) { var self = this, scope = $scope.$new(), // create a child scope so we are not polluting original one openClass = dropdownConfig.openClass, getIsOpen, unregisterWatch, setIsOpen = angular.noop, toggleInvoker = $attrs.onToggle ? $parse($attrs.onToggle) : angular.noop; this.init = function (element) { self.$element = element; if ($attrs.isOpen) { getIsOpen = $parse($attrs.isOpen); setIsOpen = getIsOpen.assign; $scope.$watch(getIsOpen, function (value) { scope.isOpen = !!value; }); } }; this.toggle = function (open) { return scope.isOpen = arguments.length ? !!open : !scope.isOpen; }; // Allow other directives to watch status this.isOpen = function () { return scope.isOpen; }; scope.getToggleElement = function () { return self.toggleElement; }; scope.focusToggleElement = function () { if (self.toggleElement) { self.toggleElement[0].focus(); } }; scope.$watch('isOpen', function (isOpen, wasOpen) { $animate[isOpen ? 'addClass' : 'removeClass'](self.$element, openClass); if (isOpen) { scope.focusToggleElement(); dropdownService.open(scope); if (!_.isEmpty(getFirstMenuItem())) { setTimeout(focusFirstItem, 0); } else { unregisterWatch = scope.$watch(function () { return self.$element.find('.dropdown-menu').children().length; }, function () { if (!_.isEmpty(getFirstMenuItem())) { setTimeout(function () { focusFirstItem(); unregisterWatch(); }, 0); } }); } } else { dropdownService.close(scope); if (unregisterWatch) { unregisterWatch(); } } setIsOpen($scope, isOpen); if (angular.isDefined(isOpen) && isOpen !== wasOpen) { toggleInvoker($scope, { open: !!isOpen }); } }); $scope.$on('$locationChangeSuccess', function () { scope.isOpen = false; }); $scope.$on('$destroy', function () { //if (stopTime) { // $interval.cancel(stopTime); //} if (unregisterWatch) { unregisterWatch(); } scope.$destroy(); }); function getFirstMenuItem() { return self.$element.find('.dropdown-menu [role=menuitem],[role=menuitemcheckbox], .dropdown-menu input').first(); } function focusFirstItem() { getFirstMenuItem().focus(); } }]) .directive('dropdown', function () { return { restrict: 'CA', controller: 'DropdownController', link: function (scope, element, attrs, dropdownCtrl) { dropdownCtrl.init(element); } }; }) .directive('dropdownToggle', function () { return { restrict: 'CA', require: '?^dropdown', link: function (scope, element, attrs, dropdownCtrl) { if (!dropdownCtrl) { return; } dropdownCtrl.toggleElement = element; var toggleDropdown = function (event) { if (event.which !== 13 && event.type === "keydown") { return; } event.preventDefault(); if (!element.hasClass('disabled') && !attrs.disabled) { scope.$apply(function () { dropdownCtrl.toggle(); }); } }; element.bind('click', toggleDropdown); element.bind('keydown', toggleDropdown); // WAI-ARIA element.attr({ 'aria-haspopup': true, 'aria-expanded': false }); scope.$watch(dropdownCtrl.isOpen, function (isOpen) { element.attr('aria-expanded', !!isOpen); }); scope.$on('$destroy', function () { element.unbind('click', toggleDropdown); element.unbind('keydown', toggleDropdown); }); } }; }); angular.module('ui.bootstrap.typeahead', ['ui.bootstrap.position', 'ui.bootstrap.bindHtml']) /** * A helper service that can parse typeahead's syntax (string provided by users) * Extracted to a separate service for ease of unit testing */ .factory('typeaheadParser', ['$parse', function ($parse) { // 00000111000000000000022200000000000000003333333333333330000000000044000 var TYPEAHEAD_REGEXP = /^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\s+([\s\S]+?)$/; return { parse: function (input) { var match = input.match(TYPEAHEAD_REGEXP); if (!match) { throw new Error( 'Expected typeahead specification in form of "_modelValue_ (as _label_)? for _item_ in _collection_"' + ' but got "' + input + '".'); } return { itemName: match[3], source: $parse(match[4]), viewMapper: $parse(match[2] || match[1]), modelMapper: $parse(match[1]) }; } }; }]) .directive('typeahead', ['$compile', '$parse', '$q', '$timeout', '$document', '$position', 'typeaheadParser', function ($compile, $parse, $q, $timeout, $document, $position, typeaheadParser) { var HOT_KEYS = [9, 13, 27, 38, 40]; return { require: 'ngModel', link: function (originalScope, element, attrs, modelCtrl) { //SUPPORTED ATTRIBUTES (OPTIONS) //minimal no of characters that needs to be entered before typeahead kicks-in var minSearch = originalScope.$eval(attrs.typeaheadMinLength) || 0; //Viktor before: var minSearch = originalScope.$eval(attrs.typeaheadMinLength) || 1; //minimal wait time after last character typed before typeahead kicks-in var waitTime = originalScope.$eval(attrs.typeaheadWaitMs) || 0; //should it restrict model values to the ones selected from the popup only? var isEditable = originalScope.$eval(attrs.typeaheadEditable) !== false; //binding to a variable that indicates if matches are being retrieved asynchronously var isLoadingSetter = $parse(attrs.typeaheadLoading).assign || angular.noop; //a callback executed when a match is selected var onSelectCallback = $parse(attrs.typeaheadOnSelect); var inputFormatter = attrs.typeaheadInputFormatter ? $parse(attrs.typeaheadInputFormatter) : undefined; var appendToBody = attrs.typeaheadAppendToBody ? originalScope.$eval(attrs.typeaheadAppendToBody) : false; var focusFirst = originalScope.$eval(attrs.typeaheadFocusFirst) !== false; //INTERNAL VARIABLES //model setter executed upon match selection var $setModelValue = $parse(attrs.ngModel).assign; //expressions used by typeahead var parserResult = typeaheadParser.parse(attrs.typeahead); var hasFocus; //create a child scope for the typeahead directive so we are not polluting original scope //with typeahead-specific data (matches, query etc.) var scope = originalScope.$new(); originalScope.$on('$destroy', function () { scope.$destroy(); }); // WAI-ARIA var popupId = 'typeahead-' + scope.$id + '-' + Math.floor(Math.random() * 10000); element.attr({ 'aria-autocomplete': 'list', 'aria-expanded': false, 'aria-owns': popupId }); //pop-up element used to display matches var popUpEl = angular.element('
'); popUpEl.attr({ id: popupId, matches: 'matches', active: 'activeIdx', select: 'select(activeIdx)', query: 'query', position: 'position' }); //custom item template if (angular.isDefined(attrs.typeaheadTemplateUrl)) { popUpEl.attr('template-url', attrs.typeaheadTemplateUrl); } var resetMatches = function () { scope.matches = []; scope.activeIdx = -1; element.attr('aria-expanded', false); }; var getMatchId = function (index) { return popupId + '-option-' + index; }; // Indicate that the specified match is the active (pre-selected) item in the list owned by this typeahead. // This attribute is added or removed automatically when the `activeIdx` changes. scope.$watch('activeIdx', function (index) { if (index < 0) { element.removeAttr('aria-activedescendant'); } else { element.attr('aria-activedescendant', getMatchId(index)); } }); var getMatchesAsync = function (inputValue) { var locals = { $viewValue: inputValue }; isLoadingSetter(originalScope, true); $q.when(parserResult.source(originalScope, locals)).then(function (matches) { //it might happen that several async queries were in progress if a user were typing fast //but we are interested only in responses that correspond to the current view value var onCurrentRequest = (inputValue === modelCtrl.$viewValue); if (onCurrentRequest && hasFocus) { if (matches && matches.length > 0) { scope.activeIdx = focusFirst ? 0 : -1; scope.matches.length = 0; //transform labels for (var i = 0; i < matches.length; i++) { locals[parserResult.itemName] = matches[i]; scope.matches.push({ id: getMatchId(i), label: parserResult.viewMapper(scope, locals), model: matches[i] }); } scope.query = inputValue; //position pop-up with matches - we need to re-calculate its position each time we are opening a window //with matches as a pop-up might be absolute-positioned and position of an input might have changed on a page //due to other elements being rendered scope.position = appendToBody ? $position.offset(element) : $position.position(element); scope.position.top = scope.position.top + element.prop('offsetHeight'); element.attr('aria-expanded', true); } else { resetMatches(); } } if (onCurrentRequest) { isLoadingSetter(originalScope, false); } }, function () { resetMatches(); isLoadingSetter(originalScope, false); }); }; resetMatches(); //we need to propagate user's query so we can higlight matches scope.query = undefined; //Declare the timeout promise var outside the function scope so that stacked calls can be cancelled later var timeoutPromise; var scheduleSearchWithTimeout = function (inputValue) { timeoutPromise = $timeout(function () { getMatchesAsync(inputValue); }, waitTime); }; var cancelPreviousTimeout = function () { if (timeoutPromise) { $timeout.cancel(timeoutPromise); } }; //plug into $parsers pipeline to open a typeahead on view changes initiated from DOM //$parsers kick-in on all the changes coming from the view as well as manually triggered by $setViewValue modelCtrl.$parsers.unshift(function (inputValue) { //Viktor new: if (inputValue === ' ') { inputValue = '' modelCtrl.$setViewValue(inputValue); return inputValue; } //Viktor new end hasFocus = true; if (minSearch === 0 || inputValue && inputValue.length >= minSearch) { // Viktor before: if (inputValue && inputValue.length >= minSearch) { if (waitTime > 0) { cancelPreviousTimeout(); scheduleSearchWithTimeout(inputValue); } else { getMatchesAsync(inputValue); } } else { isLoadingSetter(originalScope, false); cancelPreviousTimeout(); resetMatches(); } if (isEditable) { return inputValue; } else { if (!inputValue) { // Reset in case user had typed something previously. modelCtrl.$setValidity('editable', true); return inputValue; } else { modelCtrl.$setValidity('editable', false); return undefined; } } }); modelCtrl.$formatters.push(function (modelValue) { var candidateViewValue, emptyViewValue; var locals = {}; // The validity may be set to false via $parsers (see above) if // the model is restricted to selected values. If the model // is set manually it is considered to be valid. if (!isEditable) { modelCtrl.$setValidity('editable', true); } if (inputFormatter) { locals.$model = modelValue; return inputFormatter(originalScope, locals); } else { //it might happen that we don't have enough info to properly render input value //we need to check for this situation and simply return model value if we can't apply custom formatting locals[parserResult.itemName] = modelValue; candidateViewValue = parserResult.viewMapper(originalScope, locals); locals[parserResult.itemName] = undefined; emptyViewValue = parserResult.viewMapper(originalScope, locals); return candidateViewValue !== emptyViewValue ? candidateViewValue : modelValue; } }); scope.select = function (activeIdx) { //called from within the $digest() cycle var locals = {}; var model, item; locals[parserResult.itemName] = item = scope.matches[activeIdx].model; model = parserResult.modelMapper(originalScope, locals); $setModelValue(originalScope, model); modelCtrl.$setValidity('editable', true); modelCtrl.$setValidity('parse', true); onSelectCallback(originalScope, { $item: item, $model: model, $label: parserResult.viewMapper(originalScope, locals) }); resetMatches(); //return focus to the input element if a match was selected via a mouse click event // use timeout to avoid $rootScope:inprog error $timeout(function () { element[0].focus(); }, 0, false); }; //bind keyboard events: arrows up(38) / down(40), enter(13) and tab(9), esc(27) element.bind('keydown', function (evt) { //typeahead is open and an "interesting" key was pressed if (scope.matches.length === 0 || HOT_KEYS.indexOf(evt.which) === -1) { return; } // if there's nothing selected (i.e. focusFirst) and enter is hit, don't do anything if (scope.activeIdx === -1 && (evt.which === 13 || evt.which === 9)) { // Sun new : begin // this is a known issue in typeahead, suggest list should be close if user hit // enter or tab when using the typeahead-focus-first="false" option // This fix will have no impact on all our current typeahead fields since we always use // typeahead-focus-first="true" (default option) // The only field we use the false option is the global search, which is what this fix is about resetMatches(); scope.$digest(); // Sun new : end return; } evt.preventDefault(); if (evt.which === 40) { scope.activeIdx = (scope.activeIdx + 1) % scope.matches.length; scope.$digest(); } else if (evt.which === 38) { scope.activeIdx = (scope.activeIdx > 0 ? scope.activeIdx : scope.matches.length) - 1; scope.$digest(); } else if (evt.which === 13 || evt.which === 9) { scope.$apply(function () { scope.select(scope.activeIdx); }); } else if (evt.which === 27) { evt.stopPropagation(); resetMatches(); scope.$digest(); } }); element.bind('blur', function () { hasFocus = false; }); //Viktor new: element.bind('focus', function () { if (!modelCtrl.$viewValue && minSearch === 0) { modelCtrl.$setViewValue(' '); } }); //Viktor new end // Keep reference to click handler to unbind it. var dismissClickHandler = function (evt) { if (element[0] !== evt.target) { resetMatches(); scope.$digest(); } }; $document.bind('click', dismissClickHandler); originalScope.$on('$destroy', function () { $document.unbind('click', dismissClickHandler); if (appendToBody) { $popup.remove(); } // Prevent jQuery cache memory leak popUpEl.remove(); }); var $popup = $compile(popUpEl)(scope); if (appendToBody) { $document.find('body').append($popup); } else { element.after($popup); } } }; }]) .directive('typeaheadPopup', function () { return { restrict: 'EA', scope: { matches: '=', query: '=', active: '=', position: '=', select: '&' }, replace: true, templateUrl: 'template/typeahead/typeahead-popup.html', link: function (scope, element, attrs) { scope.templateUrl = attrs.templateUrl; scope.isOpen = function () { return scope.matches.length > 0; }; scope.isActive = function (matchIdx) { return scope.active === matchIdx; }; scope.selectActive = function (matchIdx) { scope.active = matchIdx; }; scope.selectMatch = function (activeIdx) { scope.select({ activeIdx: activeIdx }); }; } }; }) .directive('typeaheadMatch', ['$templateRequest', '$compile', '$parse', function ($templateRequest, $compile, $parse) { return { restrict: 'EA', scope: { index: '=', match: '=', query: '=' }, link: function (scope, element, attrs) { var tplUrl = $parse(attrs.templateUrl)(scope.$parent) || 'template/typeahead/typeahead-match.html'; $templateRequest(tplUrl).then(function (tplContent) { $compile(tplContent.trim())(scope, function (clonedElement) { element.replaceWith(clonedElement); }); }); } }; }]) .filter('typeaheadHighlight', function () { function escapeRegexp(queryToEscape) { return queryToEscape.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1'); } return function (matchItem, query) { return query ? ('' + matchItem).replace(new RegExp(escapeRegexp(query), 'gi'), '$&') : matchItem; }; }); angular.module("template/tabs/tab.html", []).run(["$templateCache", function ($templateCache) { $templateCache.put("template/tabs/tab.html", "