angular.module('ui.bootstrap.debounce', []) /** * A helper, internal service that debounces a function */ .factory('$$debounce', ['$timeout', function($timeout) { return function(callback, debounceTime) { var timeoutPromise; return function() { var self = this; var args = Array.prototype.slice(arguments); if (timeoutPromise) { $timeout.cancel(timeoutPromise); } timeoutPromise = $timeout(function() { callback.apply(self, args); }, debounceTime); }; }; }]); angular.module('ui.bootstrap.position', []) /** * A set of utility methods that can be use to retrieve position of DOM elements. * It is meant to be used where we need to absolute-position DOM elements in * relation to other, existing elements (this is the case for tooltips, popovers, * typeahead suggestions etc.). */ .factory('$uibPosition', ['$document', '$window', function($document, $window) { function getStyle(el, cssprop) { if (el.currentStyle) { //IE return el.currentStyle[cssprop]; } else if ($window.getComputedStyle) { return $window.getComputedStyle(el)[cssprop]; } // finally try and get inline style return el.style[cssprop]; } /** * Checks if a given element is statically positioned * @param element - raw DOM element */ function isStaticPositioned(element) { return (getStyle(element, 'position') || 'static' ) === 'static'; } /** * returns the closest, non-statically positioned parentOffset of a given element * @param element */ var parentOffsetEl = function(element) { var docDomEl = $document[0]; var offsetParent = element.offsetParent || docDomEl; while (offsetParent && offsetParent !== docDomEl && isStaticPositioned(offsetParent) ) { offsetParent = offsetParent.offsetParent; } return offsetParent || docDomEl; }; return { /** * Provides read-only equivalent of jQuery's position function: * http://api.jquery.com/position/ */ position: function(element) { var elBCR = this.offset(element); var offsetParentBCR = { top: 0, left: 0 }; var offsetParentEl = parentOffsetEl(element[0]); if (offsetParentEl !== $document[0]) { offsetParentBCR = this.offset(angular.element(offsetParentEl)); offsetParentBCR.top += offsetParentEl.clientTop - offsetParentEl.scrollTop; offsetParentBCR.left += offsetParentEl.clientLeft - offsetParentEl.scrollLeft; } var boundingClientRect = element[0].getBoundingClientRect(); return { width: boundingClientRect.width || element.prop('offsetWidth'), height: boundingClientRect.height || element.prop('offsetHeight'), top: elBCR.top - offsetParentBCR.top, left: elBCR.left - offsetParentBCR.left }; }, /** * Provides read-only equivalent of jQuery's offset function: * http://api.jquery.com/offset/ */ offset: function(element) { var boundingClientRect = element[0].getBoundingClientRect(); return { width: boundingClientRect.width || element.prop('offsetWidth'), height: boundingClientRect.height || element.prop('offsetHeight'), top: boundingClientRect.top + ($window.pageYOffset || $document[0].documentElement.scrollTop), left: boundingClientRect.left + ($window.pageXOffset || $document[0].documentElement.scrollLeft) }; }, /** * Provides coordinates for the targetEl in relation to hostEl */ positionElements: function(hostEl, targetEl, positionStr, appendToBody) { var positionStrParts = positionStr.split('-'); var pos0 = positionStrParts[0], pos1 = positionStrParts[1] || 'center'; var hostElPos, targetElWidth, targetElHeight, targetElPos; hostElPos = appendToBody ? this.offset(hostEl) : this.position(hostEl); targetElWidth = targetEl.prop('offsetWidth'); targetElHeight = targetEl.prop('offsetHeight'); var shiftWidth = { center: function() { return hostElPos.left + hostElPos.width / 2 - targetElWidth / 2; }, left: function() { return hostElPos.left; }, right: function() { return hostElPos.left + hostElPos.width; } }; var shiftHeight = { center: function() { return hostElPos.top + hostElPos.height / 2 - targetElHeight / 2; }, top: function() { return hostElPos.top; }, bottom: function() { return hostElPos.top + hostElPos.height; } }; switch (pos0) { case 'right': targetElPos = { top: shiftHeight[pos1](), left: shiftWidth[pos0]() }; break; case 'left': targetElPos = { top: shiftHeight[pos1](), left: hostElPos.left - targetElWidth }; break; case 'bottom': targetElPos = { top: shiftHeight[pos0](), left: shiftWidth[pos1]() }; break; default: targetElPos = { top: hostElPos.top - targetElHeight, left: shiftWidth[pos1]() }; } return targetElPos; } }; }]); angular.module('ui.bootstrap.typeahead', ['ui.bootstrap.debounce', 'ui.bootstrap.position']) /** * A helper service that can parse typeahead's syntax (string provided by users) * Extracted to a separate service for ease of unit testing */ .factory('uibTypeaheadParser', ['$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]) }; } }; }]) .controller('UibTypeaheadController', ['$scope', '$element', '$attrs', '$compile', '$parse', '$q', '$timeout', '$document', '$window', '$rootScope', '$$debounce', '$uibPosition', 'uibTypeaheadParser', function(originalScope, element, attrs, $compile, $parse, $q, $timeout, $document, $window, $rootScope, $$debounce, $position, typeaheadParser) { var HOT_KEYS = [9, 13, 27, 38, 40]; var eventDebounceTime = 200; var modelCtrl, ngModelOptions; //SUPPORTED ATTRIBUTES (OPTIONS) //minimal no of characters that needs to be entered before typeahead kicks-in var minLength = originalScope.$eval(attrs.typeaheadMinLength); if (!minLength && minLength !== 0) { minLength = 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; originalScope.$watch(attrs.typeaheadEditable, function (newVal) { isEditable = newVal !== 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); //should it select highlighted popup value when losing focus? var isSelectOnBlur = angular.isDefined(attrs.typeaheadSelectOnBlur) ? originalScope.$eval(attrs.typeaheadSelectOnBlur) : false; //binding to a variable that indicates if there were no results after the query is completed var isNoResultsSetter = $parse(attrs.typeaheadNoResults).assign || angular.noop; var inputFormatter = attrs.typeaheadInputFormatter ? $parse(attrs.typeaheadInputFormatter) : undefined; var appendToBody = attrs.typeaheadAppendToBody ? originalScope.$eval(attrs.typeaheadAppendToBody) : false; var appendTo = attrs.typeaheadAppendTo ? originalScope.$eval(attrs.typeaheadAppendTo) : null; var focusFirst = originalScope.$eval(attrs.typeaheadFocusFirst) !== false; //If input matches an item of the list exactly, select it automatically var selectOnExact = attrs.typeaheadSelectOnExact ? originalScope.$eval(attrs.typeaheadSelectOnExact) : false; //binding to a variable that indicates if dropdown is open var isOpenSetter = $parse(attrs.typeaheadIsOpen).assign || angular.noop; var showHint = originalScope.$eval(attrs.typeaheadShowHint) || false; //INTERNAL VARIABLES //model setter executed upon match selection var parsedModel = $parse(attrs.ngModel); var invokeModelSetter = $parse(attrs.ngModel + '($$$p)'); var $setModelValue = function(scope, newValue) { if (angular.isFunction(parsedModel(originalScope)) && ngModelOptions && ngModelOptions.$options && ngModelOptions.$options.getterSetter) { return invokeModelSetter(scope, {$$$p: newValue}); } return parsedModel.assign(scope, newValue); }; //expressions used by typeahead var parserResult = typeaheadParser.parse(attrs.uibTypeahead); var hasFocus; //Used to avoid bug in iOS webview where iOS keyboard does not fire //mousedown & mouseup events //Issue #3699 var selected; //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(); var offDestroy = originalScope.$on('$destroy', function() { scope.$destroy(); }); scope.$on('$destroy', offDestroy); // WAI-ARIA var popupId = 'typeahead-' + scope.$id + '-' + Math.floor(Math.random() * 10000); element.attr({ 'aria-autocomplete': 'list', 'aria-expanded': false, 'aria-owns': popupId }); var inputsContainer, hintInputElem; //add read-only input to show hint if (showHint) { inputsContainer = angular.element('
'); inputsContainer.css('position', 'relative'); element.after(inputsContainer); hintInputElem = element.clone(); hintInputElem.attr('placeholder', ''); hintInputElem.val(''); hintInputElem.css({ 'position': 'absolute', 'top': '0px', 'left': '0px', 'border-color': 'transparent', 'box-shadow': 'none', 'opacity': 1, 'background': 'none 0% 0% / auto repeat scroll padding-box border-box rgb(255, 255, 255)', 'color': '#999' }); element.css({ 'position': 'relative', 'vertical-align': 'top', 'background-color': 'transparent' }); inputsContainer.append(hintInputElem); hintInputElem.after(element); } //pop-up element used to display matches var popUpEl = angular.element(''); popUpEl.attr({ id: popupId, matches: 'matches', active: 'activeIdx', select: 'select(activeIdx)', 'move-in-progress': 'moveInProgress', query: 'query', position: 'position', 'assign-is-open': 'assignIsOpen(isOpen)' }); //custom item template if (angular.isDefined(attrs.typeaheadTemplateUrl)) { popUpEl.attr('template-url', attrs.typeaheadTemplateUrl); } if (angular.isDefined(attrs.typeaheadPopupTemplateUrl)) { popUpEl.attr('popup-template-url', attrs.typeaheadPopupTemplateUrl); } var resetHint = function() { if (showHint) { hintInputElem.val(''); } }; var resetMatches = function() { scope.matches = []; scope.activeIdx = -1; element.attr('aria-expanded', false); resetHint(); }; 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 inputIsExactMatch = function(inputValue, index) { if (scope.matches.length > index && inputValue) { return inputValue.toUpperCase() === scope.matches[index].label.toUpperCase(); } return false; }; var getMatchesAsync = function(inputValue) { var locals = {$viewValue: inputValue}; isLoadingSetter(originalScope, true); isNoResultsSetter(originalScope, false); $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; isNoResultsSetter(originalScope, false); 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 recalculatePosition(); element.attr('aria-expanded', true); //Select the single remaining option if user input matches if (selectOnExact && scope.matches.length === 1 && inputIsExactMatch(inputValue, 0)) { scope.select(0); } if (showHint) { var firstLabel = scope.matches[0].label; if (inputValue.length > 0 && firstLabel.slice(0, inputValue.length).toUpperCase() === inputValue.toUpperCase()) { hintInputElem.val(inputValue + firstLabel.slice(inputValue.length)); } else { hintInputElem.val(''); } } } else { resetMatches(); isNoResultsSetter(originalScope, true); } } if (onCurrentRequest) { isLoadingSetter(originalScope, false); } }, function() { resetMatches(); isLoadingSetter(originalScope, false); isNoResultsSetter(originalScope, true); }); }; // bind events only if appendToBody params exist - performance feature if (appendToBody) { angular.element($window).bind('resize', fireRecalculating); $document.find('body').bind('scroll', fireRecalculating); } // Declare the debounced function outside recalculating for // proper debouncing var debouncedRecalculate = $$debounce(function() { // if popup is visible if (scope.matches.length) { recalculatePosition(); } scope.moveInProgress = false; }, eventDebounceTime); // Default progress type scope.moveInProgress = false; function fireRecalculating() { if (!scope.moveInProgress) { scope.moveInProgress = true; scope.$digest(); } debouncedRecalculate(); } // recalculate actual position and set new values to scope // after digest loop is popup in right position function recalculatePosition() { scope.position = appendToBody ? $position.offset(element) : $position.position(element); scope.position.top += element.prop('offsetHeight'); } //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); } }; resetMatches(); scope.assignIsOpen = function (isOpen) { isOpenSetter(originalScope, isOpen); }; scope.select = function(activeIdx) { //called from within the $digest() cycle var locals = {}; var model, item; selected = true; 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 if (scope.$eval(attrs.typeaheadFocusOnSelect) !== false) { $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 or tab is hit, clear the results if (scope.activeIdx === -1 && (evt.which === 9 || evt.which === 13)) { resetMatches(); scope.$digest(); return; } evt.preventDefault(); if (evt.which === 40) { scope.activeIdx = (scope.activeIdx + 1) % scope.matches.length; scope.$digest(); popUpEl.children()[scope.activeIdx].scrollIntoView(false); } else if (evt.which === 38) { scope.activeIdx = (scope.activeIdx > 0 ? scope.activeIdx : scope.matches.length) - 1; scope.$digest(); popUpEl.children()[scope.activeIdx].scrollIntoView(false); } 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('focus', function () { hasFocus = true; if (minLength === 0 && !modelCtrl.$viewValue) { getMatchesAsync(modelCtrl.$viewValue); } }); element.bind('blur', function() { if (isSelectOnBlur && scope.matches.length && scope.activeIdx !== -1 && !selected) { selected = true; scope.$apply(function() { scope.select(scope.activeIdx); }); } if (!isEditable && modelCtrl.$error.editable) { modelCtrl.$viewValue = ''; element.val(''); } hasFocus = false; selected = false; }); // Keep reference to click handler to unbind it. var dismissClickHandler = function(evt) { // Issue #3973 // Firefox treats right click as a click on document if (element[0] !== evt.target && evt.which !== 3 && scope.matches.length !== 0) { resetMatches(); if (!$rootScope.$$phase) { scope.$digest(); } } }; $document.bind('click', dismissClickHandler); originalScope.$on('$destroy', function() { $document.unbind('click', dismissClickHandler); if (appendToBody || appendTo) { $popup.remove(); } if (appendToBody) { angular.element($window).unbind('resize', fireRecalculating); $document.find('body').unbind('scroll', fireRecalculating); } // Prevent jQuery cache memory leak popUpEl.remove(); if (showHint) { inputsContainer.remove(); } }); var $popup = $compile(popUpEl)(scope); if (appendToBody) { $document.find('body').append($popup); } else if (appendTo) { angular.element(appendTo).eq(0).append($popup); } else { element.after($popup); } this.init = function(_modelCtrl, _ngModelOptions) { modelCtrl = _modelCtrl; ngModelOptions = _ngModelOptions; //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) { hasFocus = true; if (minLength === 0 || inputValue && inputValue.length >= minLength) { if (waitTime > 0) { cancelPreviousTimeout(); scheduleSearchWithTimeout(inputValue); } else { getMatchesAsync(inputValue); } } else { isLoadingSetter(originalScope, false); cancelPreviousTimeout(); resetMatches(); } if (isEditable) { return inputValue; } if (!inputValue) { // Reset in case user had typed something previously. modelCtrl.$setValidity('editable', true); return null; } 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); } //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; }); }; }]) .directive('uibTypeahead', function() { return { controller: 'UibTypeaheadController', require: ['ngModel', '^?ngModelOptions', 'uibTypeahead'], link: function(originalScope, element, attrs, ctrls) { ctrls[2].init(ctrls[0], ctrls[1]); } }; }) .directive('uibTypeaheadPopup', function() { return { scope: { matches: '=', query: '=', active: '=', position: '&', moveInProgress: '=', select: '&', assignIsOpen: '&' }, replace: true, templateUrl: function(element, attrs) { return attrs.popupTemplateUrl || 'html/typeahead-popup.html'; }, link: function(scope, element, attrs) { scope.templateUrl = attrs.templateUrl; scope.isOpen = function() { var isDropdownOpen = scope.matches.length > 0; scope.assignIsOpen({ isOpen: isDropdownOpen }); return isDropdownOpen; }; scope.isActive = function(matchIdx) { return scope.active === matchIdx; }; scope.selectActive = function(matchIdx) { scope.active = matchIdx; }; scope.selectMatch = function(activeIdx) { scope.select({activeIdx: activeIdx}); }; } }; }) .directive('uibTypeaheadMatch', ['$templateRequest', '$compile', '$parse', function($templateRequest, $compile, $parse) { return { scope: { index: '=', match: '=', query: '=' }, link: function(scope, element, attrs) { var tplUrl = $parse(attrs.templateUrl)(scope.$parent) || 'html/typeahead-match.html'; $templateRequest(tplUrl).then(function(tplContent) { var tplEl = angular.element(tplContent.trim()); element.replaceWith(tplEl); $compile(tplEl)(scope); }); } }; }]) .filter('uibTypeaheadHighlight', ['$sce', '$injector', '$log', function($sce, $injector, $log) { var isSanitizePresent; isSanitizePresent = $injector.has('$sanitize'); function escapeRegexp(queryToEscape) { // Regex: capture the whole query string and replace it with the string that will be used to match // the results, for example if the capture is "a" the result will be \a return queryToEscape.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1'); } function containsHtml(matchItem) { return /<.*>/g.test(matchItem); } return function(matchItem, query) { if (!isSanitizePresent && containsHtml(matchItem)) { $log.warn('Unsafe use of typeahead please use ngSanitize'); // Warn the user about the danger } matchItem = query ? ('' + matchItem).replace(new RegExp(escapeRegexp(query), 'gi'), '$&') : matchItem; // Replaces the capture string with a the same string inside of a "strong" tag if (!isSanitizePresent) { matchItem = $sce.trustAsHtml(matchItem); // If $sanitize is not present we pack the string in a $sce object for the ng-bind-html directive } return matchItem; }; }]);