|
@@ -0,0 +1,778 @@
|
|
|
|
|
+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('<div></div>');
|
|
|
|
|
+ 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('<div uib-typeahead-popup></div>');
|
|
|
|
|
+ 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'), '<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;
|
|
|
|
|
+ };
|
|
|
|
|
+ }]);
|