Last active
December 17, 2015 16:19
-
-
Save guillaume86/5638205 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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