UNPKG

typeahead.an

Version:
278 lines (216 loc) 9.31 kB
module.exports = typeahead; require('./lib/popup'); // we need popup require('an').directive(typeahead); // delay directive registration as much as we can function typeahead($compile, $parse, $q, $timeout, $document) { // yes, we can use regular common js packages: var $position = require('./lib/utils/position')(document, window); var HOT_KEYS = [9, 13, 27, 38, 40]; return { require: 'ngModel', link: function(originalScope, element, attrs, modelCtrl) { //SUPPORTED ATTRIBUTES (OPTIONS) //minimal no of characters that needs to be entered before typeahead kicks-in var minSearch = originalScope.$eval(attrs.typeaheadMinLength) || 1; //minimal wait time after last character typed before typehead kicks-in var waitTime = originalScope.$eval(attrs.typeaheadWaitMs) || 0; //should it restrict model values to the ones selected from the popup only? var isEditable = originalScope.$eval(attrs.typeaheadEditable) !== false; //binding to a variable that indicates if matches are being retrieved asynchronously var isLoadingSetter = $parse(attrs.typeaheadLoading).assign || angular.noop; //a callback executed when a match is selected var onSelectCallback = $parse(attrs.typeaheadOnSelect); var inputFormatter = attrs.typeaheadInputFormatter ? $parse(attrs.typeaheadInputFormatter) : undefined; var appendToBody = attrs.typeaheadAppendToBody ? originalScope.$eval(attrs.typeaheadAppendToBody) : false; //INTERNAL VARIABLES //model setter executed upon match selection var $setModelValue = $parse(attrs.ngModel).assign; var parser = require('./lib/parser')($parse); //expressions used by typeahead var parserResult = parser.parse(attrs.typeahead); var hasFocus; //pop-up element used to display matches var popUpEl = angular.element('<div typeahead-popup></div>'); popUpEl.attr({ matches: 'matches', query: 'query', position: 'position' }); //custom item template var templateUrl; if (angular.isDefined(attrs.typeaheadTemplateUrl)) { templateUrl = attrs.typeaheadTemplateUrl; } //create a child scope for the typeahead directive so we are not polluting original scope //with typeahead-specific data (matches, query etc.) var scope = originalScope.$new(); originalScope.$on('$destroy', function() { scope.$destroy(); }); var resetMatches = function() { scope.matches = []; scope.activeIdx = -1; }; function selectActive(index) { var item = scope.matches[scope.activeIdx]; if (item) item.isActive = false; scope.activeIdx = index; item = scope.matches[index]; if (item) item.isActive = true; } function selectMatch(activeIdx) { var locals = {}; var model, item; locals[parserResult.itemName] = item = scope.matches[activeIdx].model; model = parserResult.modelMapper(originalScope, locals); $setModelValue(originalScope, model); modelCtrl.$setValidity('editable', true); onSelectCallback(originalScope, { $item: item, $model: model, $label: parserResult.viewMapper(originalScope, locals) }); resetMatches(); //return focus to the input element if a match was selected via a mouse click event element[0].focus(); } function getMatchesAsync(inputValue) { var locals = { $viewValue: inputValue }; isLoadingSetter(originalScope, true); $q.when(parserResult.source(originalScope, locals)).then(function(matches) { //it might happen that several async queries were in progress if a user were typing fast //but we are interested only in responses that correspond to the current view value if (inputValue === modelCtrl.$viewValue && hasFocus) { if (matches.length > 0) { scope.activeIdx = 0; scope.matches.length = 0; //transform labels for (var i = 0; i < matches.length; i++) { locals[parserResult.itemName] = matches[i]; scope.matches.push({ label: parserResult.viewMapper(scope, locals), selectMatch: selectMatch, selectActive: selectActive, templateUrl: templateUrl, model: matches[i], isActive: i === 0 }); } scope.query = inputValue; //position pop-up with matches - we need to re-calculate its position each time we are opening a window //with matches as a pop-up might be absolute-positioned and position of an input might have changed on a page //due to other elements being rendered scope.position = appendToBody ? $position.offset(element) : $position.position(element); scope.position.top = scope.position.top + element.prop('offsetHeight'); } else { resetMatches(); } isLoadingSetter(originalScope, false); } }, function() { resetMatches(); isLoadingSetter(originalScope, false); }); }; resetMatches(); //we need to propagate user's query so we can higlight matches scope.query = undefined; //Declare the timeout promise var outside the function scope so that stacked calls can be cancelled later var timeoutPromise; //plug into $parsers pipeline to open a typeahead on view changes initiated from DOM //$parsers kick-in on all the changes coming from the view as well as manually triggered by $setViewValue modelCtrl.$parsers.unshift(function(inputValue) { hasFocus = true; if (inputValue && inputValue.length >= minSearch) { if (waitTime > 0) { if (timeoutPromise) { $timeout.cancel(timeoutPromise); //cancel previous timeout } timeoutPromise = $timeout(function() { getMatchesAsync(inputValue); }, waitTime); } else { getMatchesAsync(inputValue); } } else { isLoadingSetter(originalScope, false); resetMatches(); } if (isEditable) { return inputValue; } else { if (!inputValue) { // Reset in case user had typed something previously. modelCtrl.$setValidity('editable', true); return inputValue; } else { modelCtrl.$setValidity('editable', false); return undefined; } } }); modelCtrl.$formatters.push(function(modelValue) { var candidateViewValue, emptyViewValue; var locals = {}; if (inputFormatter) { locals.$model = modelValue; return inputFormatter(originalScope, locals); } else { //it might happen that we don't have enough info to properly render input value //we need to check for this situation and simply return model value if we can't apply custom formatting locals[parserResult.itemName] = modelValue; candidateViewValue = parserResult.viewMapper(originalScope, locals); locals[parserResult.itemName] = undefined; emptyViewValue = parserResult.viewMapper(originalScope, locals); return candidateViewValue !== emptyViewValue ? candidateViewValue : modelValue; } }); scope.select = selectMatch; //bind keyboard events: arrows up(38) / down(40), enter(13) and tab(9), esc(27) element.bind('keydown', function(evt) { //typeahead is open and an "interesting" key was pressed if (scope.matches.length === 0 || HOT_KEYS.indexOf(evt.which) === -1) { return; } evt.preventDefault(); var item; if (evt.which === 40) { selectActive((scope.activeIdx + 1) % scope.matches.length); scope.$digest(); } else if (evt.which === 38) { selectActive((scope.activeIdx ? scope.activeIdx : scope.matches.length) - 1); scope.$digest(); } else if (evt.which === 13 || evt.which === 9) { scope.$apply(function() { scope.select(scope.activeIdx); }); } else if (evt.which === 27) { evt.stopPropagation(); resetMatches(); scope.$digest(); } }); element.bind('blur', function(evt) { hasFocus = false; }); // Keep reference to click handler to unbind it. var dismissClickHandler = function(evt) { if (element[0] !== evt.target) { resetMatches(); scope.$digest(); } }; $document.bind('click', dismissClickHandler); originalScope.$on('$destroy', function() { $document.unbind('click', dismissClickHandler); }); var $popup = $compile(popUpEl)(scope); if (appendToBody) { $document.find('body').append($popup); } else { element.after($popup); } } }; }