Skip to content

Instantly share code, notes, and snippets.

@guillaume86
Last active December 17, 2015 16:19
Show Gist options
  • Save guillaume86/5638205 to your computer and use it in GitHub Desktop.
Save guillaume86/5638205 to your computer and use it in GitHub Desktop.
angular.module('ui.bootstrap.typeahead.alt', ['ui.bootstrap.position'])
/**
* A helper service that can parse typeahead's syntax (string provided by users)
* Extracted to a separate service for ease of unit testing
*/
.factory('typeaheadParser', ['$parse', function ($parse) {
var TYPEAHEAD_REGEXP = /^\s*(.*?)(?:\s+as\s+(.*?))?(?:\s+grouped\s+as\s+\((.*?\s+in\s+.*?)\))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\s+(.*)$/;
var TYPEAHEAD_GROUP_REGEGXP = /^\s*(?:([\$\w][\$\w\d]*))\s+in\s+(.*)$/;
return {
parse: function (input) {
var match = input.match(TYPEAHEAD_REGEXP), modelMapper, viewMapper, source, groupItemName, groupMatchesMapper;
if (!match) {
throw new Error(
"Expected typeahead specification in form of '_modelValue_ (as _label_)? (grouped as (_item_ in _group_))? for _item_ in _collection_'" +
" but got '" + input + "'.");
}
var groupExp = match[3];
if(groupExp) {
var groupMatch = groupExp.match(TYPEAHEAD_GROUP_REGEGXP);
if (!groupMatch) {
throw new Error("Invalid grouped as expression: " + groupExp + ' REGEX: '+ TYPEAHEAD_GROUP_REGEGXP);
}
groupItemName = groupMatch[1];
groupMatchesMapper = $parse(groupMatch[2]);
}
return {
itemName: match[4],
source: $parse(match[5]),
viewMapper: $parse(match[2] || match[1]),
modelMapper: $parse(match[1]),
groupItemName: groupItemName,
groupMatchesMapper: groupMatchesMapper
};
}
};
}])
.directive('typeahead', ['$compile', '$parse', '$q', '$document', '$position', 'typeaheadParser', function ($compile, $parse, $q, $document, $position, typeaheadParser) {
var HOT_KEYS = [9, 13, 27, 38, 40];
return {
require: 'ngModel',
link: function (originalScope, element, attrs, modelCtrl) {
var selected;
//minimal no of characters that needs to be entered before typeahead kicks-in
var minSearch = originalScope.$eval(attrs.typeaheadMinLength) || 1;
//expressions used by typeahead
var parserResult = typeaheadParser.parse(attrs.typeahead);
var isGrouped = !!parserResult.groupMatchesMapper;
//should it restrict model values to the ones selected from the popup only?
var isEditable = originalScope.$eval(attrs.typeaheadEditable) !== false;
var isLoadingSetter = $parse(attrs.typeaheadLoading).assign || angular.noop;
var templateUrl = attrs.templateUrl || '';
//pop-up element used to display matches
var popUpEl = angular.element(
"<typeahead-popup " +
"template-url='" + templateUrl + "' " +
"matches='matches' " +
"groups='groups' " +
"active='activeItem' " +
"active-group='activeGroup' " +
"select='select(activeItem)' " +
"query='query' " +
"position='position'>" +
"</typeahead-popup>");
//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.activeItem = undefined;
scope.groups = [];
scope.activeGroup = undefined;
};
var getMatchesAsync = function (inputValue) {
var locals = { $viewValue: inputValue };
isLoadingSetter(originalScope, true);
$q.when(parserResult.source(scope, 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) {
if (matches.length > 0) {
if (!isGrouped) {
scope.activeItem = matches[0];
scope.matches.length = 0;
for (var i = 0; i < matches.length; i++) {
scope.matches.push(matches[i]);
}
} else {
var groups = matches;
//filter empty groups
for (var i = 0; i < groups.length; i++) {
locals[parserResult.itemName] = groups[i];
var groupMatches = parserResult.groupMatchesMapper(scope, locals);
// TODO: watch matches for async results?
if (groupMatches.length) {
scope.groups.push(groups[i]);
}
}
// Select first item of first group
scope.activeGroup = groups[0];
locals[parserResult.itemName] = scope.activeGroup;
var activeGroupMatches = parserResult.groupMatchesMapper(scope, locals);
console.log('activeGroupMatches', activeGroupMatches);
scope.activeItem = activeGroupMatches[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 = $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;
//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.push(function (inputValue) {
resetMatches();
if (selected) {
return inputValue;
} else {
if (inputValue && inputValue.length >= minSearch) {
getMatchesAsync(inputValue);
}
}
return isEditable ? inputValue : undefined;
});
modelCtrl.$render = function () {
var locals = {};
locals[parserResult.groupItemName || parserResult.itemName] = selected || modelCtrl.$viewValue;
element.val(parserResult.viewMapper(scope, locals) || modelCtrl.$viewValue);
selected = undefined;
};
scope.select = function (activeItem) {
//called from within the $digest() cycle
var locals = {};
locals[parserResult.groupItemName || parserResult.itemName] = selected = activeItem;
modelCtrl.$setViewValue(parserResult.modelMapper(scope, locals));
modelCtrl.$render();
};
//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.groups.length === 0 && scope.matches.length === 0) || HOT_KEYS.indexOf(evt.which) === -1) {
// Unfocus on esc
if (evt.which === 27 && !modelCtrl.$viewValue) {
element.blur();
}
return;
}
evt.preventDefault();
if (isGrouped) {
var activeGroupIdx = scope.groups.indexOf(scope.activeGroup);
var activeGroupMatches = [];
var activeIdx = 0;
var setActiveGroup = function (activeGroup) {
scope.activeGroup = activeGroup || scope.activeGroup;
var locals = {};
locals[parserResult.itemName] = scope.activeGroup;
activeGroupMatches = parserResult.groupMatchesMapper(scope, locals);
activeIdx = activeGroupMatches.indexOf(scope.activeItem);
};
setActiveGroup();
if (evt.which === 40) { // Down
activeIdx = (activeIdx + 1) % activeGroupMatches.length;
if (activeIdx == 0) { // Last of group
activeGroupIdx = (activeGroupIdx + 1) % scope.groups.length;
setActiveGroup(scope.groups[activeGroupIdx]);
scope.activeItem = activeGroupMatches[0];
} else {
scope.activeItem = activeGroupMatches[activeIdx];
}
scope.$digest();
} else if (evt.which === 38) { // Up
activeIdx = (activeIdx ? activeIdx : activeGroupMatches.length) - 1;
if (activeIdx == (activeGroupMatches.length - 1)) { // First of group
activeGroupIdx = (activeGroupIdx ? activeGroupIdx : scope.groups.length) - 1;
setActiveGroup(scope.groups[activeGroupIdx]);
scope.activeItem = activeGroupMatches[activeGroupMatches.length - 1];
} else {
scope.activeItem = activeGroupMatches[activeIdx];
}
scope.$digest();
} else if (evt.which === 13 || evt.which === 9) { // Select
scope.$apply(function () {
scope.select(scope.activeItem);
});
} else if (evt.which === 27) { // Esc
evt.stopPropagation();
resetMatches();
scope.$digest();
}
} else {
var activeIdx = scope.matches.indexOf(scope.activeItem);
if (evt.which === 40) { // Down
activeIdx = (activeIdx + 1) % scope.matches.length;
scope.activeItem = scope.matches[activeIdx];
scope.$digest();
} else if (evt.which === 38) { // Up
activeIdx = (activeIdx ? activeIdx : scope.matches.length) - 1;
scope.activeItem = scope.matches[activeIdx];
scope.$digest();
} else if (evt.which === 13 || evt.which === 9) { // Select
scope.$apply(function () {
scope.select(scope.activeItem);
});
} else if (evt.which === 27) { // Esc
evt.stopPropagation();
resetMatches();
scope.$digest();
}
}
});
$document.bind('click', function () {
resetMatches();
scope.$digest();
});
element.after($compile(popUpEl)(scope));
}
};
}])
.directive('typeaheadPopup', function () {
return {
restrict: 'E',
scope: {
matches: '=',
groups: '=',
query: '=',
active: '=',
activeGroup: '=',
position: '=',
select: '&',
templateUrl: '@'
},
replace: true,
templateUrl: function ($compileNode, tAttrs) {
return tAttrs.templateUrl || 'template/typeahead/typeahead.html';
},
link: function (scope, element, attrs) {
scope.isOpen = function () {
return scope.matches.length > 0
|| scope.groups.length > 0;
};
scope.isActive = function (activeItem) {
return scope.active == activeItem;
};
scope.selectActive = function (matchItem, group) {
scope.active = matchItem;
scope.activeGroup = group;
};
scope.selectMatch = function (activeItem) {
scope.select({ activeItem: activeItem });
};
}
};
})
.filter('typeaheadHighlight', function() {
function escapeRegexp(queryToEscape) {
return queryToEscape.replace(/([.?*+^$[\]\\(){}|-])/g, "\\$1");
}
return function(matchItem, query) {
return query ? matchItem.replace(new RegExp(escapeRegexp(query), 'gi'), '<strong>$&</strong>') : query;
};
});
angular.module("template/typeahead/typeahead.html", []).run(["$templateCache", function ($templateCache) {
$templateCache.put("template/typeahead/typeahead.html",
"<ul class=\"typeahead dropdown-menu\" ng-style=\"{display: isOpen()&&'block' || 'none', top: position.top+'px', left: position.left+'px'}\">\n" +
" <li ng-repeat=\"match in matches\" ng-class=\"{active: isActive(match) }\" ng-mouseenter=\"selectActive(match)\">\n" +
" <a tabindex=\"-1\" ng-click=\"selectMatch(match)\" ng-bind-html-unsafe=\"match.label | typeaheadHighlight:query\"></a>\n" +
" </li>\n" +
"</ul>");
}]);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment