typeahead.an
Version:
Typeahead directive for angular
278 lines (216 loc) • 9.31 kB
JavaScript
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);
}
}
};
}