text: module.exports = 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+as\s+(.*?))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\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) || 1;
  
        //minimal wait time after last character typed before typehead 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;
  
        //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('<div typeahead-popup></div>');
        popUpEl.attr({
          id: popupId,
          matches: 'matches',
          active: 'activeIdx',
          select: 'select(activeIdx)',
          query: 'query',
          position: 'position',
          'on-typeahead-active-callback': 'onTypeaheadActiveCallback(activeIdx)'
        });
  
        scope.onTypeaheadActiveCallback = function(activeIdx) {
          if(scope.matches && activeIdx !== undefined && scope.matches[activeIdx] &&
             scope.matches[activeIdx].model) {
              scope.onTypeaheadActive(scope.matches[activeIdx].model);
          }
        };
          //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.length > 0) {
  
                scope.activeIdx = 0;
                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;
  
        //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 (inputValue && inputValue.length >= minSearch) {
            if (waitTime > 0) {
              if (timeoutPromise) {
                $timeout.cancel(timeoutPromise);//cancel previous timeout
              }
              timeoutPromise = $timeout(function () {
                getMatchesAsync(inputValue);
              }, waitTime);
            } else {
              getMatchesAsync(inputValue);
            }
          } else {
            isLoadingSetter(originalScope, false);
            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 = {};
  
          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
          if(scope.isNoMatchString && scope.matches && activeIdx !== undefined &&
             scope.matches[activeIdx] && scope.matches[activeIdx].model &&
             scope.matches[activeIdx].model.label &&
             scope.isNoMatchString(scope.matches[activeIdx].model.label)) {
              return;
          }
          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);
  
          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;
          }
          evt.preventDefault();
          if (evt.which === 40) {
            scope.activeIdx = (scope.activeIdx + 1) % scope.matches.length;
            if( scope.matches && scope.activeIdx !== undefined && scope.matches[scope.activeIdx] &&
              scope.matches[scope.activeIdx].model) {
                scope.onTypeaheadKeyDown(scope.matches[scope.activeIdx].model);
              }
              scope.$digest();
            } else if (evt.which === 38) {
              scope.activeIdx = (scope.activeIdx ? scope.activeIdx : scope.matches.length) - 1;
              if(scope.matches && scope.activeIdx !== undefined && scope.matches[scope.activeIdx] &&
                scope.matches[scope.activeIdx].model) {
                    scope.onTypeaheadKeyDown(scope.matches[scope.activeIdx].model);
                }
                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 (evt) {
            hasFocus = false;
            if(scope.matches && scope.activeIdx !== undefined && scope.matches[scope.activeIdx] &&
              scope.matches[scope.activeIdx].model) {
              scope.onTypeaheadKeyDown(scope.matches[scope.activeIdx].model);
          }
        });
  
        // 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);
        });
  
        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:'&',
          onTypeaheadActiveCallback: '&'
        },
        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.onTypeaheadActive = function(activeIdx) {
            scope.onTypeaheadActiveCallback({activeIdx:activeIdx});
          };
  
          scope.selectActive = function (matchIdx) {
            scope.active = matchIdx;
          };
  
          scope.selectMatch = function (activeIdx) {
            scope.select({activeIdx:activeIdx});
          };
        }
      };
    })
  
    .directive('typeaheadMatch', ['$http', '$templateCache', '$compile', '$parse', function ($http, $templateCache, $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';
          $http.get(tplUrl, {cache: $templateCache}).success(function(tplContent){
             element.replaceWith($compile(tplContent.trim())(scope));
          });
        }
      };
    }])
  
    .filter('typeaheadHighlight', function() {
  
      function escapeRegexp(queryToEscape) {
        return queryToEscape.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1');
      }
  
      return function(matchItem, query) {
        return query ? ('' + matchItem).replace(new RegExp(escapeRegexp(query), 'gi'), '<strong>$&</strong>') : matchItem;
      };
    });
  