| @@ 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 { |
|