| angular.module('ui.bootstrap.typeahead', ['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', '$uibPosition', 'uibTypeaheadParser', |
| function(originalScope, element, attrs, $compile, $parse, $q, $timeout, $document, $window, $rootScope, $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; |
| |
| //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 appendToElementId = attrs.typeaheadAppendToElementId || false; |
| |
| 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; |
| |
| //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}); |
| } else { |
| 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 |
| }); |
| |
| //pop-up element used to display matches |
| var popUpEl = angular.element('<div uib-typeahead-popup></div>'); |
| popUpEl.attr({ |
| id: popupId, |
| matches: 'matches', |
| active: 'activeIdx', |
| select: 'select(activeIdx)', |
| 'move-in-progress': 'moveInProgress', |
| query: 'query', |
| position: 'position' |
| }); |
| //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 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 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); |
| } |
| } 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 timeout promise var outside the function scope so that stacked calls can be cancelled later |
| var timeoutEventPromise; |
| |
| // Default progress type |
| scope.moveInProgress = false; |
| |
| function fireRecalculating() { |
| if (!scope.moveInProgress) { |
| scope.moveInProgress = true; |
| scope.$digest(); |
| } |
| |
| // Cancel previous timeout |
| if (timeoutEventPromise) { |
| $timeout.cancel(timeoutEventPromise); |
| } |
| |
| // Debounced executing recalculate after events fired |
| timeoutEventPromise = $timeout(function() { |
| // if popup is visible |
| if (scope.matches.length) { |
| recalculatePosition(); |
| } |
| |
| scope.moveInProgress = false; |
| }, eventDebounceTime); |
| } |
| |
| // 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.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(); |
| } 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() { |
| if (isSelectOnBlur && scope.matches.length && scope.activeIdx !== -1 && !selected) { |
| selected = true; |
| scope.$apply(function() { |
| scope.select(scope.activeIdx); |
| }); |
| } |
| 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 || appendToElementId) { |
| $popup.remove(); |
| } |
| |
| if (appendToBody) { |
| angular.element($window).unbind('resize', fireRecalculating); |
| $document.find('body').unbind('scroll', fireRecalculating); |
| } |
| // Prevent jQuery cache memory leak |
| popUpEl.remove(); |
| }); |
| |
| var $popup = $compile(popUpEl)(scope); |
| |
| if (appendToBody) { |
| $document.find('body').append($popup); |
| } else if (appendToElementId !== false) { |
| angular.element($document[0].getElementById(appendToElementId)).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; |
| } else { |
| if (!inputValue) { |
| // Reset in case user had typed something previously. |
| modelCtrl.$setValidity('editable', true); |
| return null; |
| } 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; |
| } |
| }); |
| }; |
| }]) |
| |
| .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: '&' |
| }, |
| replace: true, |
| templateUrl: function(element, attrs) { |
| return attrs.popupTemplateUrl || '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('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) || 'template/typeahead/typeahead-match.html'; |
| $templateRequest(tplUrl).then(function(tplContent) { |
| $compile(tplContent.trim())(scope, function(clonedElement) { |
| element.replaceWith(clonedElement); |
| }); |
| }); |
| } |
| }; |
| }]) |
| |
| .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'), '<strong>$&</strong>') : 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; |
| }; |
| }]); |
| |
| /* Deprecated typeahead below */ |
| |
| angular.module('ui.bootstrap.typeahead') |
| .value('$typeaheadSuppressWarning', false) |
| .service('typeaheadParser', ['$parse', 'uibTypeaheadParser', '$log', '$typeaheadSuppressWarning', function($parse, uibTypeaheadParser, $log, $typeaheadSuppressWarning) { |
| if (!$typeaheadSuppressWarning) { |
| $log.warn('typeaheadParser is now deprecated. Use uibTypeaheadParser instead.'); |
| } |
| |
| return uibTypeaheadParser; |
| }]) |
| |
| .directive('typeahead', ['$compile', '$parse', '$q', '$timeout', '$document', '$window', '$rootScope', '$uibPosition', 'typeaheadParser', '$log', '$typeaheadSuppressWarning', |
| function($compile, $parse, $q, $timeout, $document, $window, $rootScope, $position, typeaheadParser, $log, $typeaheadSuppressWarning) { |
| var HOT_KEYS = [9, 13, 27, 38, 40]; |
| var eventDebounceTime = 200; |
| return { |
| require: ['ngModel', '^?ngModelOptions'], |
| link: function(originalScope, element, attrs, ctrls) { |
| if (!$typeaheadSuppressWarning) { |
| $log.warn('typeahead is now deprecated. Use uib-typeahead instead.'); |
| } |
| var modelCtrl = ctrls[0]; |
| var ngModelOptions = ctrls[1]; |
| //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; |
| |
| //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 appendToElementId = attrs.typeaheadAppendToElementId || false; |
| |
| 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; |
| |
| //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}); |
| } else { |
| return parsedModel.assign(scope, newValue); |
| } |
| }; |
| |
| //expressions used by typeahead |
| var parserResult = typeaheadParser.parse(attrs.typeahead); |
| |
| 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 |
| }); |
| |
| //pop-up element used to display matches |
| var popUpEl = angular.element('<div typeahead-popup></div>'); |
| popUpEl.attr({ |
| id: popupId, |
| matches: 'matches', |
| active: 'activeIdx', |
| select: 'select(activeIdx)', |
| 'move-in-progress': 'moveInProgress', |
| query: 'query', |
| position: 'position' |
| }); |
| //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 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 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); |
| } |
| } 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 timeout promise var outside the function scope so that stacked calls can be cancelled later |
| var timeoutEventPromise; |
| |
| // Default progress type |
| scope.moveInProgress = false; |
| |
| function fireRecalculating() { |
| if (!scope.moveInProgress) { |
| scope.moveInProgress = true; |
| scope.$digest(); |
| } |
| |
| // Cancel previous timeout |
| if (timeoutEventPromise) { |
| $timeout.cancel(timeoutEventPromise); |
| } |
| |
| // Debounced executing recalculate after events fired |
| timeoutEventPromise = $timeout(function() { |
| // if popup is visible |
| if (scope.matches.length) { |
| recalculatePosition(); |
| } |
| |
| scope.moveInProgress = false; |
| }, eventDebounceTime); |
| } |
| |
| // 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'); |
| } |
| |
| 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) { |
| 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; |
| } else { |
| if (!inputValue) { |
| // Reset in case user had typed something previously. |
| modelCtrl.$setValidity('editable', true); |
| return null; |
| } 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; |
| |
| 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(); |
| } 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() { |
| if (isSelectOnBlur && scope.matches.length && scope.activeIdx !== -1 && !selected) { |
| selected = true; |
| scope.$apply(function() { |
| scope.select(scope.activeIdx); |
| }); |
| } |
| 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 || appendToElementId) { |
| $popup.remove(); |
| } |
| |
| if (appendToBody) { |
| angular.element($window).unbind('resize', fireRecalculating); |
| $document.find('body').unbind('scroll', fireRecalculating); |
| } |
| // Prevent jQuery cache memory leak |
| popUpEl.remove(); |
| }); |
| |
| var $popup = $compile(popUpEl)(scope); |
| |
| if (appendToBody) { |
| $document.find('body').append($popup); |
| } else if (appendToElementId !== false) { |
| angular.element($document[0].getElementById(appendToElementId)).append($popup); |
| } else { |
| element.after($popup); |
| } |
| } |
| }; |
| }]) |
| |
| .directive('typeaheadPopup', ['$typeaheadSuppressWarning', '$log', function($typeaheadSuppressWarning, $log) { |
| return { |
| scope: { |
| matches: '=', |
| query: '=', |
| active: '=', |
| position: '&', |
| moveInProgress: '=', |
| select: '&' |
| }, |
| replace: true, |
| templateUrl: function(element, attrs) { |
| return attrs.popupTemplateUrl || 'template/typeahead/typeahead-popup.html'; |
| }, |
| link: function(scope, element, attrs) { |
| |
| if (!$typeaheadSuppressWarning) { |
| $log.warn('typeahead-popup is now deprecated. Use uib-typeahead-popup instead.'); |
| } |
| 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', '$typeaheadSuppressWarning', '$log', function($templateRequest, $compile, $parse, $typeaheadSuppressWarning, $log) { |
| return { |
| restrict: 'EA', |
| scope: { |
| index: '=', |
| match: '=', |
| query: '=' |
| }, |
| link:function(scope, element, attrs) { |
| if (!$typeaheadSuppressWarning) { |
| $log.warn('typeahead-match is now deprecated. Use uib-typeahead-match instead.'); |
| } |
| |
| 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', ['$sce', '$injector', '$log', '$typeaheadSuppressWarning', function($sce, $injector, $log, $typeaheadSuppressWarning) { |
| 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 (!$typeaheadSuppressWarning) { |
| $log.warn('typeaheadHighlight is now deprecated. Use uibTypeaheadHighlight instead.'); |
| } |
| |
| 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'), '<strong>$&</strong>') : 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; |
| }; |
| }]); |