@@ 6269-6799 (lines=531) @@ | ||
6266 | }]) |
|
6267 | ||
6268 | .controller('UibTypeaheadController', ['$scope', '$element', '$attrs', '$compile', '$parse', '$q', '$timeout', '$document', '$window', '$rootScope', '$$debounce', '$uibPosition', 'uibTypeaheadParser', |
|
6269 | function(originalScope, element, attrs, $compile, $parse, $q, $timeout, $document, $window, $rootScope, $$debounce, $position, typeaheadParser) { |
|
6270 | var HOT_KEYS = [9, 13, 27, 38, 40]; |
|
6271 | var eventDebounceTime = 200; |
|
6272 | var modelCtrl, ngModelOptions; |
|
6273 | //SUPPORTED ATTRIBUTES (OPTIONS) |
|
6274 | ||
6275 | //minimal no of characters that needs to be entered before typeahead kicks-in |
|
6276 | var minLength = originalScope.$eval(attrs.typeaheadMinLength); |
|
6277 | if (!minLength && minLength !== 0) { |
|
6278 | minLength = 1; |
|
6279 | } |
|
6280 | ||
6281 | originalScope.$watch(attrs.typeaheadMinLength, function (newVal) { |
|
6282 | minLength = !newVal && newVal !== 0 ? 1 : newVal; |
|
6283 | }); |
|
6284 | ||
6285 | //minimal wait time after last character typed before typeahead kicks-in |
|
6286 | var waitTime = originalScope.$eval(attrs.typeaheadWaitMs) || 0; |
|
6287 | ||
6288 | //should it restrict model values to the ones selected from the popup only? |
|
6289 | var isEditable = originalScope.$eval(attrs.typeaheadEditable) !== false; |
|
6290 | originalScope.$watch(attrs.typeaheadEditable, function (newVal) { |
|
6291 | isEditable = newVal !== false; |
|
6292 | }); |
|
6293 | ||
6294 | //binding to a variable that indicates if matches are being retrieved asynchronously |
|
6295 | var isLoadingSetter = $parse(attrs.typeaheadLoading).assign || angular.noop; |
|
6296 | ||
6297 | //a callback executed when a match is selected |
|
6298 | var onSelectCallback = $parse(attrs.typeaheadOnSelect); |
|
6299 | ||
6300 | //should it select highlighted popup value when losing focus? |
|
6301 | var isSelectOnBlur = angular.isDefined(attrs.typeaheadSelectOnBlur) ? originalScope.$eval(attrs.typeaheadSelectOnBlur) : false; |
|
6302 | ||
6303 | //binding to a variable that indicates if there were no results after the query is completed |
|
6304 | var isNoResultsSetter = $parse(attrs.typeaheadNoResults).assign || angular.noop; |
|
6305 | ||
6306 | var inputFormatter = attrs.typeaheadInputFormatter ? $parse(attrs.typeaheadInputFormatter) : undefined; |
|
6307 | ||
6308 | var appendToBody = attrs.typeaheadAppendToBody ? originalScope.$eval(attrs.typeaheadAppendToBody) : false; |
|
6309 | ||
6310 | var appendTo = attrs.typeaheadAppendTo ? |
|
6311 | originalScope.$eval(attrs.typeaheadAppendTo) : null; |
|
6312 | ||
6313 | var focusFirst = originalScope.$eval(attrs.typeaheadFocusFirst) !== false; |
|
6314 | ||
6315 | //If input matches an item of the list exactly, select it automatically |
|
6316 | var selectOnExact = attrs.typeaheadSelectOnExact ? originalScope.$eval(attrs.typeaheadSelectOnExact) : false; |
|
6317 | ||
6318 | //binding to a variable that indicates if dropdown is open |
|
6319 | var isOpenSetter = $parse(attrs.typeaheadIsOpen).assign || angular.noop; |
|
6320 | ||
6321 | var showHint = originalScope.$eval(attrs.typeaheadShowHint) || false; |
|
6322 | ||
6323 | //INTERNAL VARIABLES |
|
6324 | ||
6325 | //model setter executed upon match selection |
|
6326 | var parsedModel = $parse(attrs.ngModel); |
|
6327 | var invokeModelSetter = $parse(attrs.ngModel + '($$$p)'); |
|
6328 | var $setModelValue = function(scope, newValue) { |
|
6329 | if (angular.isFunction(parsedModel(originalScope)) && |
|
6330 | ngModelOptions && ngModelOptions.$options && ngModelOptions.$options.getterSetter) { |
|
6331 | return invokeModelSetter(scope, {$$$p: newValue}); |
|
6332 | } |
|
6333 | ||
6334 | return parsedModel.assign(scope, newValue); |
|
6335 | }; |
|
6336 | ||
6337 | //expressions used by typeahead |
|
6338 | var parserResult = typeaheadParser.parse(attrs.uibTypeahead); |
|
6339 | ||
6340 | var hasFocus; |
|
6341 | ||
6342 | //Used to avoid bug in iOS webview where iOS keyboard does not fire |
|
6343 | //mousedown & mouseup events |
|
6344 | //Issue #3699 |
|
6345 | var selected; |
|
6346 | ||
6347 | //create a child scope for the typeahead directive so we are not polluting original scope |
|
6348 | //with typeahead-specific data (matches, query etc.) |
|
6349 | var scope = originalScope.$new(); |
|
6350 | var offDestroy = originalScope.$on('$destroy', function() { |
|
6351 | scope.$destroy(); |
|
6352 | }); |
|
6353 | scope.$on('$destroy', offDestroy); |
|
6354 | ||
6355 | // WAI-ARIA |
|
6356 | var popupId = 'typeahead-' + scope.$id + '-' + Math.floor(Math.random() * 10000); |
|
6357 | element.attr({ |
|
6358 | 'aria-autocomplete': 'list', |
|
6359 | 'aria-expanded': false, |
|
6360 | 'aria-owns': popupId |
|
6361 | }); |
|
6362 | ||
6363 | var inputsContainer, hintInputElem; |
|
6364 | //add read-only input to show hint |
|
6365 | if (showHint) { |
|
6366 | inputsContainer = angular.element('<div></div>'); |
|
6367 | inputsContainer.css('position', 'relative'); |
|
6368 | element.after(inputsContainer); |
|
6369 | hintInputElem = element.clone(); |
|
6370 | hintInputElem.attr('placeholder', ''); |
|
6371 | hintInputElem.attr('tabindex', '-1'); |
|
6372 | hintInputElem.val(''); |
|
6373 | hintInputElem.css({ |
|
6374 | 'position': 'absolute', |
|
6375 | 'top': '0px', |
|
6376 | 'left': '0px', |
|
6377 | 'border-color': 'transparent', |
|
6378 | 'box-shadow': 'none', |
|
6379 | 'opacity': 1, |
|
6380 | 'background': 'none 0% 0% / auto repeat scroll padding-box border-box rgb(255, 255, 255)', |
|
6381 | 'color': '#999' |
|
6382 | }); |
|
6383 | element.css({ |
|
6384 | 'position': 'relative', |
|
6385 | 'vertical-align': 'top', |
|
6386 | 'background-color': 'transparent' |
|
6387 | }); |
|
6388 | inputsContainer.append(hintInputElem); |
|
6389 | hintInputElem.after(element); |
|
6390 | } |
|
6391 | ||
6392 | //pop-up element used to display matches |
|
6393 | var popUpEl = angular.element('<div uib-typeahead-popup></div>'); |
|
6394 | popUpEl.attr({ |
|
6395 | id: popupId, |
|
6396 | matches: 'matches', |
|
6397 | active: 'activeIdx', |
|
6398 | select: 'select(activeIdx, evt)', |
|
6399 | 'move-in-progress': 'moveInProgress', |
|
6400 | query: 'query', |
|
6401 | position: 'position', |
|
6402 | 'assign-is-open': 'assignIsOpen(isOpen)', |
|
6403 | debounce: 'debounceUpdate' |
|
6404 | }); |
|
6405 | //custom item template |
|
6406 | if (angular.isDefined(attrs.typeaheadTemplateUrl)) { |
|
6407 | popUpEl.attr('template-url', attrs.typeaheadTemplateUrl); |
|
6408 | } |
|
6409 | ||
6410 | if (angular.isDefined(attrs.typeaheadPopupTemplateUrl)) { |
|
6411 | popUpEl.attr('popup-template-url', attrs.typeaheadPopupTemplateUrl); |
|
6412 | } |
|
6413 | ||
6414 | var resetHint = function() { |
|
6415 | if (showHint) { |
|
6416 | hintInputElem.val(''); |
|
6417 | } |
|
6418 | }; |
|
6419 | ||
6420 | var resetMatches = function() { |
|
6421 | scope.matches = []; |
|
6422 | scope.activeIdx = -1; |
|
6423 | element.attr('aria-expanded', false); |
|
6424 | resetHint(); |
|
6425 | }; |
|
6426 | ||
6427 | var getMatchId = function(index) { |
|
6428 | return popupId + '-option-' + index; |
|
6429 | }; |
|
6430 | ||
6431 | // Indicate that the specified match is the active (pre-selected) item in the list owned by this typeahead. |
|
6432 | // This attribute is added or removed automatically when the `activeIdx` changes. |
|
6433 | scope.$watch('activeIdx', function(index) { |
|
6434 | if (index < 0) { |
|
6435 | element.removeAttr('aria-activedescendant'); |
|
6436 | } else { |
|
6437 | element.attr('aria-activedescendant', getMatchId(index)); |
|
6438 | } |
|
6439 | }); |
|
6440 | ||
6441 | var inputIsExactMatch = function(inputValue, index) { |
|
6442 | if (scope.matches.length > index && inputValue) { |
|
6443 | return inputValue.toUpperCase() === scope.matches[index].label.toUpperCase(); |
|
6444 | } |
|
6445 | ||
6446 | return false; |
|
6447 | }; |
|
6448 | ||
6449 | var getMatchesAsync = function(inputValue, evt) { |
|
6450 | var locals = {$viewValue: inputValue}; |
|
6451 | isLoadingSetter(originalScope, true); |
|
6452 | isNoResultsSetter(originalScope, false); |
|
6453 | $q.when(parserResult.source(originalScope, locals)).then(function(matches) { |
|
6454 | //it might happen that several async queries were in progress if a user were typing fast |
|
6455 | //but we are interested only in responses that correspond to the current view value |
|
6456 | var onCurrentRequest = inputValue === modelCtrl.$viewValue; |
|
6457 | if (onCurrentRequest && hasFocus) { |
|
6458 | if (matches && matches.length > 0) { |
|
6459 | scope.activeIdx = focusFirst ? 0 : -1; |
|
6460 | isNoResultsSetter(originalScope, false); |
|
6461 | scope.matches.length = 0; |
|
6462 | ||
6463 | //transform labels |
|
6464 | for (var i = 0; i < matches.length; i++) { |
|
6465 | locals[parserResult.itemName] = matches[i]; |
|
6466 | scope.matches.push({ |
|
6467 | id: getMatchId(i), |
|
6468 | label: parserResult.viewMapper(scope, locals), |
|
6469 | model: matches[i] |
|
6470 | }); |
|
6471 | } |
|
6472 | ||
6473 | scope.query = inputValue; |
|
6474 | //position pop-up with matches - we need to re-calculate its position each time we are opening a window |
|
6475 | //with matches as a pop-up might be absolute-positioned and position of an input might have changed on a page |
|
6476 | //due to other elements being rendered |
|
6477 | recalculatePosition(); |
|
6478 | ||
6479 | element.attr('aria-expanded', true); |
|
6480 | ||
6481 | //Select the single remaining option if user input matches |
|
6482 | if (selectOnExact && scope.matches.length === 1 && inputIsExactMatch(inputValue, 0)) { |
|
6483 | if (angular.isNumber(scope.debounceUpdate) || angular.isObject(scope.debounceUpdate)) { |
|
6484 | $$debounce(function() { |
|
6485 | scope.select(0, evt); |
|
6486 | }, angular.isNumber(scope.debounceUpdate) ? scope.debounceUpdate : scope.debounceUpdate['default']); |
|
6487 | } else { |
|
6488 | scope.select(0, evt); |
|
6489 | } |
|
6490 | } |
|
6491 | ||
6492 | if (showHint) { |
|
6493 | var firstLabel = scope.matches[0].label; |
|
6494 | if (angular.isString(inputValue) && |
|
6495 | inputValue.length > 0 && |
|
6496 | firstLabel.slice(0, inputValue.length).toUpperCase() === inputValue.toUpperCase()) { |
|
6497 | hintInputElem.val(inputValue + firstLabel.slice(inputValue.length)); |
|
6498 | } else { |
|
6499 | hintInputElem.val(''); |
|
6500 | } |
|
6501 | } |
|
6502 | } else { |
|
6503 | resetMatches(); |
|
6504 | isNoResultsSetter(originalScope, true); |
|
6505 | } |
|
6506 | } |
|
6507 | if (onCurrentRequest) { |
|
6508 | isLoadingSetter(originalScope, false); |
|
6509 | } |
|
6510 | }, function() { |
|
6511 | resetMatches(); |
|
6512 | isLoadingSetter(originalScope, false); |
|
6513 | isNoResultsSetter(originalScope, true); |
|
6514 | }); |
|
6515 | }; |
|
6516 | ||
6517 | // bind events only if appendToBody params exist - performance feature |
|
6518 | if (appendToBody) { |
|
6519 | angular.element($window).on('resize', fireRecalculating); |
|
6520 | $document.find('body').on('scroll', fireRecalculating); |
|
6521 | } |
|
6522 | ||
6523 | // Declare the debounced function outside recalculating for |
|
6524 | // proper debouncing |
|
6525 | var debouncedRecalculate = $$debounce(function() { |
|
6526 | // if popup is visible |
|
6527 | if (scope.matches.length) { |
|
6528 | recalculatePosition(); |
|
6529 | } |
|
6530 | ||
6531 | scope.moveInProgress = false; |
|
6532 | }, eventDebounceTime); |
|
6533 | ||
6534 | // Default progress type |
|
6535 | scope.moveInProgress = false; |
|
6536 | ||
6537 | function fireRecalculating() { |
|
6538 | if (!scope.moveInProgress) { |
|
6539 | scope.moveInProgress = true; |
|
6540 | scope.$digest(); |
|
6541 | } |
|
6542 | ||
6543 | debouncedRecalculate(); |
|
6544 | } |
|
6545 | ||
6546 | // recalculate actual position and set new values to scope |
|
6547 | // after digest loop is popup in right position |
|
6548 | function recalculatePosition() { |
|
6549 | scope.position = appendToBody ? $position.offset(element) : $position.position(element); |
|
6550 | scope.position.top += element.prop('offsetHeight'); |
|
6551 | } |
|
6552 | ||
6553 | //we need to propagate user's query so we can higlight matches |
|
6554 | scope.query = undefined; |
|
6555 | ||
6556 | //Declare the timeout promise var outside the function scope so that stacked calls can be cancelled later |
|
6557 | var timeoutPromise; |
|
6558 | ||
6559 | var scheduleSearchWithTimeout = function(inputValue) { |
|
6560 | timeoutPromise = $timeout(function() { |
|
6561 | getMatchesAsync(inputValue); |
|
6562 | }, waitTime); |
|
6563 | }; |
|
6564 | ||
6565 | var cancelPreviousTimeout = function() { |
|
6566 | if (timeoutPromise) { |
|
6567 | $timeout.cancel(timeoutPromise); |
|
6568 | } |
|
6569 | }; |
|
6570 | ||
6571 | resetMatches(); |
|
6572 | ||
6573 | scope.assignIsOpen = function (isOpen) { |
|
6574 | isOpenSetter(originalScope, isOpen); |
|
6575 | }; |
|
6576 | ||
6577 | scope.select = function(activeIdx, evt) { |
|
6578 | //called from within the $digest() cycle |
|
6579 | var locals = {}; |
|
6580 | var model, item; |
|
6581 | ||
6582 | selected = true; |
|
6583 | locals[parserResult.itemName] = item = scope.matches[activeIdx].model; |
|
6584 | model = parserResult.modelMapper(originalScope, locals); |
|
6585 | $setModelValue(originalScope, model); |
|
6586 | modelCtrl.$setValidity('editable', true); |
|
6587 | modelCtrl.$setValidity('parse', true); |
|
6588 | ||
6589 | onSelectCallback(originalScope, { |
|
6590 | $item: item, |
|
6591 | $model: model, |
|
6592 | $label: parserResult.viewMapper(originalScope, locals), |
|
6593 | $event: evt |
|
6594 | }); |
|
6595 | ||
6596 | resetMatches(); |
|
6597 | ||
6598 | //return focus to the input element if a match was selected via a mouse click event |
|
6599 | // use timeout to avoid $rootScope:inprog error |
|
6600 | if (scope.$eval(attrs.typeaheadFocusOnSelect) !== false) { |
|
6601 | $timeout(function() { element[0].focus(); }, 0, false); |
|
6602 | } |
|
6603 | }; |
|
6604 | ||
6605 | //bind keyboard events: arrows up(38) / down(40), enter(13) and tab(9), esc(27) |
|
6606 | element.on('keydown', function(evt) { |
|
6607 | //typeahead is open and an "interesting" key was pressed |
|
6608 | if (scope.matches.length === 0 || HOT_KEYS.indexOf(evt.which) === -1) { |
|
6609 | return; |
|
6610 | } |
|
6611 | ||
6612 | /** |
|
6613 | * if there's nothing selected (i.e. focusFirst) and enter or tab is hit |
|
6614 | * or |
|
6615 | * shift + tab is pressed to bring focus to the previous element |
|
6616 | * then clear the results |
|
6617 | */ |
|
6618 | if (scope.activeIdx === -1 && (evt.which === 9 || evt.which === 13) || evt.which === 9 && !!evt.shiftKey) { |
|
6619 | resetMatches(); |
|
6620 | scope.$digest(); |
|
6621 | return; |
|
6622 | } |
|
6623 | ||
6624 | evt.preventDefault(); |
|
6625 | var target; |
|
6626 | switch (evt.which) { |
|
6627 | case 9: |
|
6628 | case 13: |
|
6629 | scope.$apply(function () { |
|
6630 | if (angular.isNumber(scope.debounceUpdate) || angular.isObject(scope.debounceUpdate)) { |
|
6631 | $$debounce(function() { |
|
6632 | scope.select(scope.activeIdx, evt); |
|
6633 | }, angular.isNumber(scope.debounceUpdate) ? scope.debounceUpdate : scope.debounceUpdate['default']); |
|
6634 | } else { |
|
6635 | scope.select(scope.activeIdx, evt); |
|
6636 | } |
|
6637 | }); |
|
6638 | break; |
|
6639 | case 27: |
|
6640 | evt.stopPropagation(); |
|
6641 | ||
6642 | resetMatches(); |
|
6643 | originalScope.$digest(); |
|
6644 | break; |
|
6645 | case 38: |
|
6646 | scope.activeIdx = (scope.activeIdx > 0 ? scope.activeIdx : scope.matches.length) - 1; |
|
6647 | scope.$digest(); |
|
6648 | target = popUpEl.find('li')[scope.activeIdx]; |
|
6649 | target.parentNode.scrollTop = target.offsetTop; |
|
6650 | break; |
|
6651 | case 40: |
|
6652 | scope.activeIdx = (scope.activeIdx + 1) % scope.matches.length; |
|
6653 | scope.$digest(); |
|
6654 | target = popUpEl.find('li')[scope.activeIdx]; |
|
6655 | target.parentNode.scrollTop = target.offsetTop; |
|
6656 | break; |
|
6657 | } |
|
6658 | }); |
|
6659 | ||
6660 | element.bind('focus', function (evt) { |
|
6661 | hasFocus = true; |
|
6662 | if (minLength === 0 && !modelCtrl.$viewValue) { |
|
6663 | $timeout(function() { |
|
6664 | getMatchesAsync(modelCtrl.$viewValue, evt); |
|
6665 | }, 0); |
|
6666 | } |
|
6667 | }); |
|
6668 | ||
6669 | element.bind('blur', function(evt) { |
|
6670 | if (isSelectOnBlur && scope.matches.length && scope.activeIdx !== -1 && !selected) { |
|
6671 | selected = true; |
|
6672 | scope.$apply(function() { |
|
6673 | if (angular.isObject(scope.debounceUpdate) && angular.isNumber(scope.debounceUpdate.blur)) { |
|
6674 | $$debounce(function() { |
|
6675 | scope.select(scope.activeIdx, evt); |
|
6676 | }, scope.debounceUpdate.blur); |
|
6677 | } else { |
|
6678 | scope.select(scope.activeIdx, evt); |
|
6679 | } |
|
6680 | }); |
|
6681 | } |
|
6682 | if (!isEditable && modelCtrl.$error.editable) { |
|
6683 | modelCtrl.$setViewValue(); |
|
6684 | // Reset validity as we are clearing |
|
6685 | modelCtrl.$setValidity('editable', true); |
|
6686 | modelCtrl.$setValidity('parse', true); |
|
6687 | element.val(''); |
|
6688 | } |
|
6689 | hasFocus = false; |
|
6690 | selected = false; |
|
6691 | }); |
|
6692 | ||
6693 | // Keep reference to click handler to unbind it. |
|
6694 | var dismissClickHandler = function(evt) { |
|
6695 | // Issue #3973 |
|
6696 | // Firefox treats right click as a click on document |
|
6697 | if (element[0] !== evt.target && evt.which !== 3 && scope.matches.length !== 0) { |
|
6698 | resetMatches(); |
|
6699 | if (!$rootScope.$$phase) { |
|
6700 | originalScope.$digest(); |
|
6701 | } |
|
6702 | } |
|
6703 | }; |
|
6704 | ||
6705 | $document.on('click', dismissClickHandler); |
|
6706 | ||
6707 | originalScope.$on('$destroy', function() { |
|
6708 | $document.off('click', dismissClickHandler); |
|
6709 | if (appendToBody || appendTo) { |
|
6710 | $popup.remove(); |
|
6711 | } |
|
6712 | ||
6713 | if (appendToBody) { |
|
6714 | angular.element($window).off('resize', fireRecalculating); |
|
6715 | $document.find('body').off('scroll', fireRecalculating); |
|
6716 | } |
|
6717 | // Prevent jQuery cache memory leak |
|
6718 | popUpEl.remove(); |
|
6719 | ||
6720 | if (showHint) { |
|
6721 | inputsContainer.remove(); |
|
6722 | } |
|
6723 | }); |
|
6724 | ||
6725 | var $popup = $compile(popUpEl)(scope); |
|
6726 | ||
6727 | if (appendToBody) { |
|
6728 | $document.find('body').append($popup); |
|
6729 | } else if (appendTo) { |
|
6730 | angular.element(appendTo).eq(0).append($popup); |
|
6731 | } else { |
|
6732 | element.after($popup); |
|
6733 | } |
|
6734 | ||
6735 | this.init = function(_modelCtrl, _ngModelOptions) { |
|
6736 | modelCtrl = _modelCtrl; |
|
6737 | ngModelOptions = _ngModelOptions; |
|
6738 | ||
6739 | scope.debounceUpdate = modelCtrl.$options && $parse(modelCtrl.$options.debounce)(originalScope); |
|
6740 | ||
6741 | //plug into $parsers pipeline to open a typeahead on view changes initiated from DOM |
|
6742 | //$parsers kick-in on all the changes coming from the view as well as manually triggered by $setViewValue |
|
6743 | modelCtrl.$parsers.unshift(function(inputValue) { |
|
6744 | hasFocus = true; |
|
6745 | ||
6746 | if (minLength === 0 || inputValue && inputValue.length >= minLength) { |
|
6747 | if (waitTime > 0) { |
|
6748 | cancelPreviousTimeout(); |
|
6749 | scheduleSearchWithTimeout(inputValue); |
|
6750 | } else { |
|
6751 | getMatchesAsync(inputValue); |
|
6752 | } |
|
6753 | } else { |
|
6754 | isLoadingSetter(originalScope, false); |
|
6755 | cancelPreviousTimeout(); |
|
6756 | resetMatches(); |
|
6757 | } |
|
6758 | ||
6759 | if (isEditable) { |
|
6760 | return inputValue; |
|
6761 | } |
|
6762 | ||
6763 | if (!inputValue) { |
|
6764 | // Reset in case user had typed something previously. |
|
6765 | modelCtrl.$setValidity('editable', true); |
|
6766 | return null; |
|
6767 | } |
|
6768 | ||
6769 | modelCtrl.$setValidity('editable', false); |
|
6770 | return undefined; |
|
6771 | }); |
|
6772 | ||
6773 | modelCtrl.$formatters.push(function(modelValue) { |
|
6774 | var candidateViewValue, emptyViewValue; |
|
6775 | var locals = {}; |
|
6776 | ||
6777 | // The validity may be set to false via $parsers (see above) if |
|
6778 | // the model is restricted to selected values. If the model |
|
6779 | // is set manually it is considered to be valid. |
|
6780 | if (!isEditable) { |
|
6781 | modelCtrl.$setValidity('editable', true); |
|
6782 | } |
|
6783 | ||
6784 | if (inputFormatter) { |
|
6785 | locals.$model = modelValue; |
|
6786 | return inputFormatter(originalScope, locals); |
|
6787 | } |
|
6788 | ||
6789 | //it might happen that we don't have enough info to properly render input value |
|
6790 | //we need to check for this situation and simply return model value if we can't apply custom formatting |
|
6791 | locals[parserResult.itemName] = modelValue; |
|
6792 | candidateViewValue = parserResult.viewMapper(originalScope, locals); |
|
6793 | locals[parserResult.itemName] = undefined; |
|
6794 | emptyViewValue = parserResult.viewMapper(originalScope, locals); |
|
6795 | ||
6796 | return candidateViewValue !== emptyViewValue ? candidateViewValue : modelValue; |
|
6797 | }); |
|
6798 | }; |
|
6799 | }]) |
|
6800 | ||
6801 | .directive('uibTypeahead', function() { |
|
6802 | return { |
@@ 6268-6798 (lines=531) @@ | ||
6265 | }]) |
|
6266 | ||
6267 | .controller('UibTypeaheadController', ['$scope', '$element', '$attrs', '$compile', '$parse', '$q', '$timeout', '$document', '$window', '$rootScope', '$$debounce', '$uibPosition', 'uibTypeaheadParser', |
|
6268 | function(originalScope, element, attrs, $compile, $parse, $q, $timeout, $document, $window, $rootScope, $$debounce, $position, typeaheadParser) { |
|
6269 | var HOT_KEYS = [9, 13, 27, 38, 40]; |
|
6270 | var eventDebounceTime = 200; |
|
6271 | var modelCtrl, ngModelOptions; |
|
6272 | //SUPPORTED ATTRIBUTES (OPTIONS) |
|
6273 | ||
6274 | //minimal no of characters that needs to be entered before typeahead kicks-in |
|
6275 | var minLength = originalScope.$eval(attrs.typeaheadMinLength); |
|
6276 | if (!minLength && minLength !== 0) { |
|
6277 | minLength = 1; |
|
6278 | } |
|
6279 | ||
6280 | originalScope.$watch(attrs.typeaheadMinLength, function (newVal) { |
|
6281 | minLength = !newVal && newVal !== 0 ? 1 : newVal; |
|
6282 | }); |
|
6283 | ||
6284 | //minimal wait time after last character typed before typeahead kicks-in |
|
6285 | var waitTime = originalScope.$eval(attrs.typeaheadWaitMs) || 0; |
|
6286 | ||
6287 | //should it restrict model values to the ones selected from the popup only? |
|
6288 | var isEditable = originalScope.$eval(attrs.typeaheadEditable) !== false; |
|
6289 | originalScope.$watch(attrs.typeaheadEditable, function (newVal) { |
|
6290 | isEditable = newVal !== false; |
|
6291 | }); |
|
6292 | ||
6293 | //binding to a variable that indicates if matches are being retrieved asynchronously |
|
6294 | var isLoadingSetter = $parse(attrs.typeaheadLoading).assign || angular.noop; |
|
6295 | ||
6296 | //a callback executed when a match is selected |
|
6297 | var onSelectCallback = $parse(attrs.typeaheadOnSelect); |
|
6298 | ||
6299 | //should it select highlighted popup value when losing focus? |
|
6300 | var isSelectOnBlur = angular.isDefined(attrs.typeaheadSelectOnBlur) ? originalScope.$eval(attrs.typeaheadSelectOnBlur) : false; |
|
6301 | ||
6302 | //binding to a variable that indicates if there were no results after the query is completed |
|
6303 | var isNoResultsSetter = $parse(attrs.typeaheadNoResults).assign || angular.noop; |
|
6304 | ||
6305 | var inputFormatter = attrs.typeaheadInputFormatter ? $parse(attrs.typeaheadInputFormatter) : undefined; |
|
6306 | ||
6307 | var appendToBody = attrs.typeaheadAppendToBody ? originalScope.$eval(attrs.typeaheadAppendToBody) : false; |
|
6308 | ||
6309 | var appendTo = attrs.typeaheadAppendTo ? |
|
6310 | originalScope.$eval(attrs.typeaheadAppendTo) : null; |
|
6311 | ||
6312 | var focusFirst = originalScope.$eval(attrs.typeaheadFocusFirst) !== false; |
|
6313 | ||
6314 | //If input matches an item of the list exactly, select it automatically |
|
6315 | var selectOnExact = attrs.typeaheadSelectOnExact ? originalScope.$eval(attrs.typeaheadSelectOnExact) : false; |
|
6316 | ||
6317 | //binding to a variable that indicates if dropdown is open |
|
6318 | var isOpenSetter = $parse(attrs.typeaheadIsOpen).assign || angular.noop; |
|
6319 | ||
6320 | var showHint = originalScope.$eval(attrs.typeaheadShowHint) || false; |
|
6321 | ||
6322 | //INTERNAL VARIABLES |
|
6323 | ||
6324 | //model setter executed upon match selection |
|
6325 | var parsedModel = $parse(attrs.ngModel); |
|
6326 | var invokeModelSetter = $parse(attrs.ngModel + '($$$p)'); |
|
6327 | var $setModelValue = function(scope, newValue) { |
|
6328 | if (angular.isFunction(parsedModel(originalScope)) && |
|
6329 | ngModelOptions && ngModelOptions.$options && ngModelOptions.$options.getterSetter) { |
|
6330 | return invokeModelSetter(scope, {$$$p: newValue}); |
|
6331 | } |
|
6332 | ||
6333 | return parsedModel.assign(scope, newValue); |
|
6334 | }; |
|
6335 | ||
6336 | //expressions used by typeahead |
|
6337 | var parserResult = typeaheadParser.parse(attrs.uibTypeahead); |
|
6338 | ||
6339 | var hasFocus; |
|
6340 | ||
6341 | //Used to avoid bug in iOS webview where iOS keyboard does not fire |
|
6342 | //mousedown & mouseup events |
|
6343 | //Issue #3699 |
|
6344 | var selected; |
|
6345 | ||
6346 | //create a child scope for the typeahead directive so we are not polluting original scope |
|
6347 | //with typeahead-specific data (matches, query etc.) |
|
6348 | var scope = originalScope.$new(); |
|
6349 | var offDestroy = originalScope.$on('$destroy', function() { |
|
6350 | scope.$destroy(); |
|
6351 | }); |
|
6352 | scope.$on('$destroy', offDestroy); |
|
6353 | ||
6354 | // WAI-ARIA |
|
6355 | var popupId = 'typeahead-' + scope.$id + '-' + Math.floor(Math.random() * 10000); |
|
6356 | element.attr({ |
|
6357 | 'aria-autocomplete': 'list', |
|
6358 | 'aria-expanded': false, |
|
6359 | 'aria-owns': popupId |
|
6360 | }); |
|
6361 | ||
6362 | var inputsContainer, hintInputElem; |
|
6363 | //add read-only input to show hint |
|
6364 | if (showHint) { |
|
6365 | inputsContainer = angular.element('<div></div>'); |
|
6366 | inputsContainer.css('position', 'relative'); |
|
6367 | element.after(inputsContainer); |
|
6368 | hintInputElem = element.clone(); |
|
6369 | hintInputElem.attr('placeholder', ''); |
|
6370 | hintInputElem.attr('tabindex', '-1'); |
|
6371 | hintInputElem.val(''); |
|
6372 | hintInputElem.css({ |
|
6373 | 'position': 'absolute', |
|
6374 | 'top': '0px', |
|
6375 | 'left': '0px', |
|
6376 | 'border-color': 'transparent', |
|
6377 | 'box-shadow': 'none', |
|
6378 | 'opacity': 1, |
|
6379 | 'background': 'none 0% 0% / auto repeat scroll padding-box border-box rgb(255, 255, 255)', |
|
6380 | 'color': '#999' |
|
6381 | }); |
|
6382 | element.css({ |
|
6383 | 'position': 'relative', |
|
6384 | 'vertical-align': 'top', |
|
6385 | 'background-color': 'transparent' |
|
6386 | }); |
|
6387 | inputsContainer.append(hintInputElem); |
|
6388 | hintInputElem.after(element); |
|
6389 | } |
|
6390 | ||
6391 | //pop-up element used to display matches |
|
6392 | var popUpEl = angular.element('<div uib-typeahead-popup></div>'); |
|
6393 | popUpEl.attr({ |
|
6394 | id: popupId, |
|
6395 | matches: 'matches', |
|
6396 | active: 'activeIdx', |
|
6397 | select: 'select(activeIdx, evt)', |
|
6398 | 'move-in-progress': 'moveInProgress', |
|
6399 | query: 'query', |
|
6400 | position: 'position', |
|
6401 | 'assign-is-open': 'assignIsOpen(isOpen)', |
|
6402 | debounce: 'debounceUpdate' |
|
6403 | }); |
|
6404 | //custom item template |
|
6405 | if (angular.isDefined(attrs.typeaheadTemplateUrl)) { |
|
6406 | popUpEl.attr('template-url', attrs.typeaheadTemplateUrl); |
|
6407 | } |
|
6408 | ||
6409 | if (angular.isDefined(attrs.typeaheadPopupTemplateUrl)) { |
|
6410 | popUpEl.attr('popup-template-url', attrs.typeaheadPopupTemplateUrl); |
|
6411 | } |
|
6412 | ||
6413 | var resetHint = function() { |
|
6414 | if (showHint) { |
|
6415 | hintInputElem.val(''); |
|
6416 | } |
|
6417 | }; |
|
6418 | ||
6419 | var resetMatches = function() { |
|
6420 | scope.matches = []; |
|
6421 | scope.activeIdx = -1; |
|
6422 | element.attr('aria-expanded', false); |
|
6423 | resetHint(); |
|
6424 | }; |
|
6425 | ||
6426 | var getMatchId = function(index) { |
|
6427 | return popupId + '-option-' + index; |
|
6428 | }; |
|
6429 | ||
6430 | // Indicate that the specified match is the active (pre-selected) item in the list owned by this typeahead. |
|
6431 | // This attribute is added or removed automatically when the `activeIdx` changes. |
|
6432 | scope.$watch('activeIdx', function(index) { |
|
6433 | if (index < 0) { |
|
6434 | element.removeAttr('aria-activedescendant'); |
|
6435 | } else { |
|
6436 | element.attr('aria-activedescendant', getMatchId(index)); |
|
6437 | } |
|
6438 | }); |
|
6439 | ||
6440 | var inputIsExactMatch = function(inputValue, index) { |
|
6441 | if (scope.matches.length > index && inputValue) { |
|
6442 | return inputValue.toUpperCase() === scope.matches[index].label.toUpperCase(); |
|
6443 | } |
|
6444 | ||
6445 | return false; |
|
6446 | }; |
|
6447 | ||
6448 | var getMatchesAsync = function(inputValue, evt) { |
|
6449 | var locals = {$viewValue: inputValue}; |
|
6450 | isLoadingSetter(originalScope, true); |
|
6451 | isNoResultsSetter(originalScope, false); |
|
6452 | $q.when(parserResult.source(originalScope, locals)).then(function(matches) { |
|
6453 | //it might happen that several async queries were in progress if a user were typing fast |
|
6454 | //but we are interested only in responses that correspond to the current view value |
|
6455 | var onCurrentRequest = inputValue === modelCtrl.$viewValue; |
|
6456 | if (onCurrentRequest && hasFocus) { |
|
6457 | if (matches && matches.length > 0) { |
|
6458 | scope.activeIdx = focusFirst ? 0 : -1; |
|
6459 | isNoResultsSetter(originalScope, false); |
|
6460 | scope.matches.length = 0; |
|
6461 | ||
6462 | //transform labels |
|
6463 | for (var i = 0; i < matches.length; i++) { |
|
6464 | locals[parserResult.itemName] = matches[i]; |
|
6465 | scope.matches.push({ |
|
6466 | id: getMatchId(i), |
|
6467 | label: parserResult.viewMapper(scope, locals), |
|
6468 | model: matches[i] |
|
6469 | }); |
|
6470 | } |
|
6471 | ||
6472 | scope.query = inputValue; |
|
6473 | //position pop-up with matches - we need to re-calculate its position each time we are opening a window |
|
6474 | //with matches as a pop-up might be absolute-positioned and position of an input might have changed on a page |
|
6475 | //due to other elements being rendered |
|
6476 | recalculatePosition(); |
|
6477 | ||
6478 | element.attr('aria-expanded', true); |
|
6479 | ||
6480 | //Select the single remaining option if user input matches |
|
6481 | if (selectOnExact && scope.matches.length === 1 && inputIsExactMatch(inputValue, 0)) { |
|
6482 | if (angular.isNumber(scope.debounceUpdate) || angular.isObject(scope.debounceUpdate)) { |
|
6483 | $$debounce(function() { |
|
6484 | scope.select(0, evt); |
|
6485 | }, angular.isNumber(scope.debounceUpdate) ? scope.debounceUpdate : scope.debounceUpdate['default']); |
|
6486 | } else { |
|
6487 | scope.select(0, evt); |
|
6488 | } |
|
6489 | } |
|
6490 | ||
6491 | if (showHint) { |
|
6492 | var firstLabel = scope.matches[0].label; |
|
6493 | if (angular.isString(inputValue) && |
|
6494 | inputValue.length > 0 && |
|
6495 | firstLabel.slice(0, inputValue.length).toUpperCase() === inputValue.toUpperCase()) { |
|
6496 | hintInputElem.val(inputValue + firstLabel.slice(inputValue.length)); |
|
6497 | } else { |
|
6498 | hintInputElem.val(''); |
|
6499 | } |
|
6500 | } |
|
6501 | } else { |
|
6502 | resetMatches(); |
|
6503 | isNoResultsSetter(originalScope, true); |
|
6504 | } |
|
6505 | } |
|
6506 | if (onCurrentRequest) { |
|
6507 | isLoadingSetter(originalScope, false); |
|
6508 | } |
|
6509 | }, function() { |
|
6510 | resetMatches(); |
|
6511 | isLoadingSetter(originalScope, false); |
|
6512 | isNoResultsSetter(originalScope, true); |
|
6513 | }); |
|
6514 | }; |
|
6515 | ||
6516 | // bind events only if appendToBody params exist - performance feature |
|
6517 | if (appendToBody) { |
|
6518 | angular.element($window).on('resize', fireRecalculating); |
|
6519 | $document.find('body').on('scroll', fireRecalculating); |
|
6520 | } |
|
6521 | ||
6522 | // Declare the debounced function outside recalculating for |
|
6523 | // proper debouncing |
|
6524 | var debouncedRecalculate = $$debounce(function() { |
|
6525 | // if popup is visible |
|
6526 | if (scope.matches.length) { |
|
6527 | recalculatePosition(); |
|
6528 | } |
|
6529 | ||
6530 | scope.moveInProgress = false; |
|
6531 | }, eventDebounceTime); |
|
6532 | ||
6533 | // Default progress type |
|
6534 | scope.moveInProgress = false; |
|
6535 | ||
6536 | function fireRecalculating() { |
|
6537 | if (!scope.moveInProgress) { |
|
6538 | scope.moveInProgress = true; |
|
6539 | scope.$digest(); |
|
6540 | } |
|
6541 | ||
6542 | debouncedRecalculate(); |
|
6543 | } |
|
6544 | ||
6545 | // recalculate actual position and set new values to scope |
|
6546 | // after digest loop is popup in right position |
|
6547 | function recalculatePosition() { |
|
6548 | scope.position = appendToBody ? $position.offset(element) : $position.position(element); |
|
6549 | scope.position.top += element.prop('offsetHeight'); |
|
6550 | } |
|
6551 | ||
6552 | //we need to propagate user's query so we can higlight matches |
|
6553 | scope.query = undefined; |
|
6554 | ||
6555 | //Declare the timeout promise var outside the function scope so that stacked calls can be cancelled later |
|
6556 | var timeoutPromise; |
|
6557 | ||
6558 | var scheduleSearchWithTimeout = function(inputValue) { |
|
6559 | timeoutPromise = $timeout(function() { |
|
6560 | getMatchesAsync(inputValue); |
|
6561 | }, waitTime); |
|
6562 | }; |
|
6563 | ||
6564 | var cancelPreviousTimeout = function() { |
|
6565 | if (timeoutPromise) { |
|
6566 | $timeout.cancel(timeoutPromise); |
|
6567 | } |
|
6568 | }; |
|
6569 | ||
6570 | resetMatches(); |
|
6571 | ||
6572 | scope.assignIsOpen = function (isOpen) { |
|
6573 | isOpenSetter(originalScope, isOpen); |
|
6574 | }; |
|
6575 | ||
6576 | scope.select = function(activeIdx, evt) { |
|
6577 | //called from within the $digest() cycle |
|
6578 | var locals = {}; |
|
6579 | var model, item; |
|
6580 | ||
6581 | selected = true; |
|
6582 | locals[parserResult.itemName] = item = scope.matches[activeIdx].model; |
|
6583 | model = parserResult.modelMapper(originalScope, locals); |
|
6584 | $setModelValue(originalScope, model); |
|
6585 | modelCtrl.$setValidity('editable', true); |
|
6586 | modelCtrl.$setValidity('parse', true); |
|
6587 | ||
6588 | onSelectCallback(originalScope, { |
|
6589 | $item: item, |
|
6590 | $model: model, |
|
6591 | $label: parserResult.viewMapper(originalScope, locals), |
|
6592 | $event: evt |
|
6593 | }); |
|
6594 | ||
6595 | resetMatches(); |
|
6596 | ||
6597 | //return focus to the input element if a match was selected via a mouse click event |
|
6598 | // use timeout to avoid $rootScope:inprog error |
|
6599 | if (scope.$eval(attrs.typeaheadFocusOnSelect) !== false) { |
|
6600 | $timeout(function() { element[0].focus(); }, 0, false); |
|
6601 | } |
|
6602 | }; |
|
6603 | ||
6604 | //bind keyboard events: arrows up(38) / down(40), enter(13) and tab(9), esc(27) |
|
6605 | element.on('keydown', function(evt) { |
|
6606 | //typeahead is open and an "interesting" key was pressed |
|
6607 | if (scope.matches.length === 0 || HOT_KEYS.indexOf(evt.which) === -1) { |
|
6608 | return; |
|
6609 | } |
|
6610 | ||
6611 | /** |
|
6612 | * if there's nothing selected (i.e. focusFirst) and enter or tab is hit |
|
6613 | * or |
|
6614 | * shift + tab is pressed to bring focus to the previous element |
|
6615 | * then clear the results |
|
6616 | */ |
|
6617 | if (scope.activeIdx === -1 && (evt.which === 9 || evt.which === 13) || evt.which === 9 && !!evt.shiftKey) { |
|
6618 | resetMatches(); |
|
6619 | scope.$digest(); |
|
6620 | return; |
|
6621 | } |
|
6622 | ||
6623 | evt.preventDefault(); |
|
6624 | var target; |
|
6625 | switch (evt.which) { |
|
6626 | case 9: |
|
6627 | case 13: |
|
6628 | scope.$apply(function () { |
|
6629 | if (angular.isNumber(scope.debounceUpdate) || angular.isObject(scope.debounceUpdate)) { |
|
6630 | $$debounce(function() { |
|
6631 | scope.select(scope.activeIdx, evt); |
|
6632 | }, angular.isNumber(scope.debounceUpdate) ? scope.debounceUpdate : scope.debounceUpdate['default']); |
|
6633 | } else { |
|
6634 | scope.select(scope.activeIdx, evt); |
|
6635 | } |
|
6636 | }); |
|
6637 | break; |
|
6638 | case 27: |
|
6639 | evt.stopPropagation(); |
|
6640 | ||
6641 | resetMatches(); |
|
6642 | originalScope.$digest(); |
|
6643 | break; |
|
6644 | case 38: |
|
6645 | scope.activeIdx = (scope.activeIdx > 0 ? scope.activeIdx : scope.matches.length) - 1; |
|
6646 | scope.$digest(); |
|
6647 | target = popUpEl.find('li')[scope.activeIdx]; |
|
6648 | target.parentNode.scrollTop = target.offsetTop; |
|
6649 | break; |
|
6650 | case 40: |
|
6651 | scope.activeIdx = (scope.activeIdx + 1) % scope.matches.length; |
|
6652 | scope.$digest(); |
|
6653 | target = popUpEl.find('li')[scope.activeIdx]; |
|
6654 | target.parentNode.scrollTop = target.offsetTop; |
|
6655 | break; |
|
6656 | } |
|
6657 | }); |
|
6658 | ||
6659 | element.bind('focus', function (evt) { |
|
6660 | hasFocus = true; |
|
6661 | if (minLength === 0 && !modelCtrl.$viewValue) { |
|
6662 | $timeout(function() { |
|
6663 | getMatchesAsync(modelCtrl.$viewValue, evt); |
|
6664 | }, 0); |
|
6665 | } |
|
6666 | }); |
|
6667 | ||
6668 | element.bind('blur', function(evt) { |
|
6669 | if (isSelectOnBlur && scope.matches.length && scope.activeIdx !== -1 && !selected) { |
|
6670 | selected = true; |
|
6671 | scope.$apply(function() { |
|
6672 | if (angular.isObject(scope.debounceUpdate) && angular.isNumber(scope.debounceUpdate.blur)) { |
|
6673 | $$debounce(function() { |
|
6674 | scope.select(scope.activeIdx, evt); |
|
6675 | }, scope.debounceUpdate.blur); |
|
6676 | } else { |
|
6677 | scope.select(scope.activeIdx, evt); |
|
6678 | } |
|
6679 | }); |
|
6680 | } |
|
6681 | if (!isEditable && modelCtrl.$error.editable) { |
|
6682 | modelCtrl.$setViewValue(); |
|
6683 | // Reset validity as we are clearing |
|
6684 | modelCtrl.$setValidity('editable', true); |
|
6685 | modelCtrl.$setValidity('parse', true); |
|
6686 | element.val(''); |
|
6687 | } |
|
6688 | hasFocus = false; |
|
6689 | selected = false; |
|
6690 | }); |
|
6691 | ||
6692 | // Keep reference to click handler to unbind it. |
|
6693 | var dismissClickHandler = function(evt) { |
|
6694 | // Issue #3973 |
|
6695 | // Firefox treats right click as a click on document |
|
6696 | if (element[0] !== evt.target && evt.which !== 3 && scope.matches.length !== 0) { |
|
6697 | resetMatches(); |
|
6698 | if (!$rootScope.$$phase) { |
|
6699 | originalScope.$digest(); |
|
6700 | } |
|
6701 | } |
|
6702 | }; |
|
6703 | ||
6704 | $document.on('click', dismissClickHandler); |
|
6705 | ||
6706 | originalScope.$on('$destroy', function() { |
|
6707 | $document.off('click', dismissClickHandler); |
|
6708 | if (appendToBody || appendTo) { |
|
6709 | $popup.remove(); |
|
6710 | } |
|
6711 | ||
6712 | if (appendToBody) { |
|
6713 | angular.element($window).off('resize', fireRecalculating); |
|
6714 | $document.find('body').off('scroll', fireRecalculating); |
|
6715 | } |
|
6716 | // Prevent jQuery cache memory leak |
|
6717 | popUpEl.remove(); |
|
6718 | ||
6719 | if (showHint) { |
|
6720 | inputsContainer.remove(); |
|
6721 | } |
|
6722 | }); |
|
6723 | ||
6724 | var $popup = $compile(popUpEl)(scope); |
|
6725 | ||
6726 | if (appendToBody) { |
|
6727 | $document.find('body').append($popup); |
|
6728 | } else if (appendTo) { |
|
6729 | angular.element(appendTo).eq(0).append($popup); |
|
6730 | } else { |
|
6731 | element.after($popup); |
|
6732 | } |
|
6733 | ||
6734 | this.init = function(_modelCtrl, _ngModelOptions) { |
|
6735 | modelCtrl = _modelCtrl; |
|
6736 | ngModelOptions = _ngModelOptions; |
|
6737 | ||
6738 | scope.debounceUpdate = modelCtrl.$options && $parse(modelCtrl.$options.debounce)(originalScope); |
|
6739 | ||
6740 | //plug into $parsers pipeline to open a typeahead on view changes initiated from DOM |
|
6741 | //$parsers kick-in on all the changes coming from the view as well as manually triggered by $setViewValue |
|
6742 | modelCtrl.$parsers.unshift(function(inputValue) { |
|
6743 | hasFocus = true; |
|
6744 | ||
6745 | if (minLength === 0 || inputValue && inputValue.length >= minLength) { |
|
6746 | if (waitTime > 0) { |
|
6747 | cancelPreviousTimeout(); |
|
6748 | scheduleSearchWithTimeout(inputValue); |
|
6749 | } else { |
|
6750 | getMatchesAsync(inputValue); |
|
6751 | } |
|
6752 | } else { |
|
6753 | isLoadingSetter(originalScope, false); |
|
6754 | cancelPreviousTimeout(); |
|
6755 | resetMatches(); |
|
6756 | } |
|
6757 | ||
6758 | if (isEditable) { |
|
6759 | return inputValue; |
|
6760 | } |
|
6761 | ||
6762 | if (!inputValue) { |
|
6763 | // Reset in case user had typed something previously. |
|
6764 | modelCtrl.$setValidity('editable', true); |
|
6765 | return null; |
|
6766 | } |
|
6767 | ||
6768 | modelCtrl.$setValidity('editable', false); |
|
6769 | return undefined; |
|
6770 | }); |
|
6771 | ||
6772 | modelCtrl.$formatters.push(function(modelValue) { |
|
6773 | var candidateViewValue, emptyViewValue; |
|
6774 | var locals = {}; |
|
6775 | ||
6776 | // The validity may be set to false via $parsers (see above) if |
|
6777 | // the model is restricted to selected values. If the model |
|
6778 | // is set manually it is considered to be valid. |
|
6779 | if (!isEditable) { |
|
6780 | modelCtrl.$setValidity('editable', true); |
|
6781 | } |
|
6782 | ||
6783 | if (inputFormatter) { |
|
6784 | locals.$model = modelValue; |
|
6785 | return inputFormatter(originalScope, locals); |
|
6786 | } |
|
6787 | ||
6788 | //it might happen that we don't have enough info to properly render input value |
|
6789 | //we need to check for this situation and simply return model value if we can't apply custom formatting |
|
6790 | locals[parserResult.itemName] = modelValue; |
|
6791 | candidateViewValue = parserResult.viewMapper(originalScope, locals); |
|
6792 | locals[parserResult.itemName] = undefined; |
|
6793 | emptyViewValue = parserResult.viewMapper(originalScope, locals); |
|
6794 | ||
6795 | return candidateViewValue !== emptyViewValue ? candidateViewValue : modelValue; |
|
6796 | }); |
|
6797 | }; |
|
6798 | }]) |
|
6799 | ||
6800 | .directive('uibTypeahead', function() { |
|
6801 | return { |