Skip to content

Instantly share code, notes, and snippets.

@rupe120
Created September 27, 2015 14:46
Show Gist options
  • Save rupe120/9d095f5d43535e9b2786 to your computer and use it in GitHub Desktop.
Save rupe120/9d095f5d43535e9b2786 to your computer and use it in GitHub Desktop.
(function() {
'use strict';
/**
* Based on the ui.bootstrap.typeahead control from ui.bootstrap 0.12.0
*
* Has dependencies on the typeaheadParser directive and ui.bootstrap.position module from the ui.bootstrap module
*/
window.appIndependent
// Based on the bindHtmlUnsafe2 directive in the ui.bootstrap.bindHtml module
.directive('bindHtmlUnsafe2', function () {
return function (scope, element, attr) {
element.addClass('ng-binding').data('$binding', attr.bindHtmlUnsafe2);
scope.$watch(attr.bindHtmlUnsafe2, function bindHtmlUnsafeWatchAction(value) {
if (value === undefined || value === null)
value = '';
element.html(value);
});
};
})
///**
// * 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) {
// // 00000111000000000000022200000000000000003333333333333330000000000044000
// var TYPEAHEAD_REGEXP = /^\s*([\s\S]+?)(?:\s+as\s+([\s\S]+?))?\s+for\s+(?:([\$\w][\$\w\d]*))\s+in\s+([\s\S]+?)$/;
// return {
// parse: function (input) {
// var match = input.match(TYPEAHEAD_REGEXP);
// if (!match) {
// throw new Error(
// 'Expected typeahead specification in form of "_modelValue_ (as _label_)? for _item_ in _collection_"' +
// ' but got "' + input + '".');
// }
// return {
// itemName: match[3],
// source: $parse(match[4]),
// viewMapper: $parse(match[2] || match[1]),
// modelMapper: $parse(match[1])
// };
// }
// };
// }])
.directive('typeaheadAdvanced', ['$compile', '$parse', '$q', '$timeout', '$document', '$position', 'typeaheadParser', '$exceptionHandler', '$rootScope',
function ($compile, $parse, $q, $timeout, $document, $position, typeaheadParser, $exceptionHandler, $rootScope) {
var HOT_KEYS = [9, 13, 27, 38, 40];
return {
require: 'ngModel',
link: function (originalScope, element, attrs, modelCtrl) {
//if (console && console.debug) {
// console.debug("typeahead.link");
//}
// *JRR*
element.attr('autocomplete', 'off');
// Add the clear (x) link, for clearing the field
element.addClass('clearable');
var wrapperDiv = $document[0].createElement('div');
wrapperDiv.setAttribute('class', 'clearable-wrapper');
element[0].parentNode.insertBefore(wrapperDiv, element[0]);
wrapperDiv.appendChild(element[0]);
var clearButton = $document[0].createElement('button');
clearButton.setAttribute('class', 'close-icon glyphicon glyphicon-remove');
clearButton.setAttribute('tabIndex', '-1');
angular.element(clearButton).bind("click", function (event) {
// Ensure the clean was a click
if (event.clientX > 0) {
scope.select(-1);
modelCtrl.$render();
}
});
//element[0].appendChild(clearButton);
element[0].parentNode.insertBefore(clearButton, element[0].nextSibling);
//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;
// *JRR*
//Should this directive ensure that either a selection is made from the popup or the model value is undefined. Defaults to true
var requireSelection = !attrs.hasOwnProperty('typeaheadRequireSelection') ? true : originalScope.$eval(attrs.typeaheadRequireSelection) === true;
// *JRR*
//Should the suggestions popup automatically show? Defaults to true
var displayPopupOnClick = !attrs.hasOwnProperty('typeaheadDisplayPopupOnClick') ? true : originalScope.$eval(attrs.typeaheadDisplayPopupOnClick) === true;
//If true then force the minSearch to zero
if (displayPopupOnClick) {
minSearch = 0;
}
//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 onKeydownCallback = $parse(attrs.typeaheadOnKeydown);
var inputFormatter = attrs.typeaheadInputFormatter ? $parse(attrs.typeaheadInputFormatter) : undefined;
var appendToBody = attrs.typeaheadAppendToBody ? originalScope.$eval(attrs.typeaheadAppendToBody) : false;
var focusFirst = attrs.typeaheadFocusFirst ? originalScope.$eval(attrs.typeaheadFocusFirst) : true;
var autoSelectFirst = attrs.typeaheadAutoSelectFirst ? originalScope.$eval(attrs.typeaheadAutoSelectFirst) : false;
if(autoSelectFirst){
focusFirst = true;
}
var managePopupHeight = originalScope.$eval(attrs.typeaheadManagePopupHeight) === true;
var defaultValue = attrs.typeaheadDefaultValue ? $parse(attrs.typeaheadDefaultValue) : undefined;
//INTERNAL VARIABLES
// *JRR*
var $getModelValue = $parse(attrs.ngModel);
//model setter executed upon match selection
var $setModelValue = $getModelValue.assign;
//expressions used by typeahead
var parserResult = typeaheadParser.parse(attrs.typeaheadAdvanced);
// *JRR*
var originalModelValue;
var originalInputValue;
// *JRR*
var isValidSelection;
//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();
});
scope.hasFocus = false;
scope.noResults = false;
scope.managePopupHeight = managePopupHeight;
//if (console && console.debug) {
// console.debug("typeahead.scope.managePopupHeight = " + scope.managePopupHeight);
//}
// WAI-ARIA
var popupId = '';
// JSR - Setting popup id based on input id. If no id exists, generate random id.
if (attrs.id) {
popupId = attrs.id + '-typeahead';
} else {
popupId = scope.$id + '-' + Math.floor(Math.random() * 10000) + '-typeahead';
}
element.attr({
'aria-autocomplete': 'list',
'aria-expanded': false,
'aria-owns': popupId
});
//pop-up element used to display matches
var popUpEl = angular.element('<div typeahead-advanced-popup></div>');
popUpEl.attr({
id: popupId,
matches: 'matches',
'has-focus': 'hasFocus',
'no-results': 'noResults',
active: 'activeIdx',
select: 'select(activeIdx, true)',
query: 'query',
'manage-popup-height': 'managePopupHeight',
position: 'position',
'position-popup': 'positionPopup'
});
//custom item template
if (angular.isDefined(attrs.typeaheadTemplateUrl)) {
popUpEl.attr('template-url', attrs.typeaheadTemplateUrl);
}
var resetMatches = function () {
scope.matches = [];
scope.activeIdx = -1;
element.attr('aria-expanded', false);
};
var getMatchId = function (index) {
return popupId + '-option-' + index;
};
var getModelDisplay = function (modelValue) {
var formatters = modelCtrl.$formatters,
idx = formatters.length;
var viewValue = modelValue;
while (idx--) {
viewValue = formatters[idx](viewValue);
}
return viewValue;
}
// Indicate that the specified match is the active (pre-selected) item in the list owned by this typeahead.
// This attribute is added or removed automatically when the `activeIdx` changes.
scope.$watch('activeIdx', function (index) {
if (index < 0) {
element.removeAttr('aria-activedescendant');
} else {
element.attr('aria-activedescendant', getMatchId(index));
}
});
// The positioning of the popup in a scope method to allow for it to be called from different page events
scope.positionPopup = function () {
//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');
//if (console && console.debug) {
// console.debug("typeahead.positionPopup - " + JSON.stringify(scope.position));
//}
};
var positionPopupListener;
var registerPopupPositioning = function () {
//if (console && console.debug) {
// console.debug("typeahead add positionPopup");
//}
// Add a listener to reposition on $digest in case a change just displayed a message above the typeahead
positionPopupListener = scope.$watch(function () {
var position = appendToBody ? $position.offset(element) : $position.position(element);
return position.top;
}, function (newValue, oldValue) {
if (newValue != oldValue) {
scope.positionPopup();
}
});
}
scope.$watch(function () { return scope.matches.length > 0; }, function (newValue) {
// Remove the repositioning listener when the popup is no longer displayed
if (!newValue && positionPopupListener) {
//if (console && console.debug) {
// console.debug("typeahead remove positionPopup");
//}
positionPopupListener();
}
});
var getMatchesAsync = function (inputValue) {
var locals = { $viewValue: typeof inputValue === "undefined" ? '' : inputValue };
isLoadingSetter(originalScope, true);
$q.when(parserResult.source(originalScope, locals)).then(function (matches) {
scope.noResults = false;
//if (console && console.debug) {
// console.debug("typeahead.getMatchesAsync inputValue = " + inputValue + ", modelCtrl.$viewValue = " + modelCtrl.$viewValue + ", onCurrentRequest = " + (inputValue === modelCtrl.$viewValue) + ", scope.hasFocus = " + scope.hasFocus + ", matches.length = " + matches.length + ", modelCtrl.$viewValue === defaultValue() = " + (defaultValue && inputValue === '' && modelCtrl.$viewValue === defaultValue()));
//}
//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
var onCurrentRequest = (inputValue === modelCtrl.$viewValue);
if (onCurrentRequest && scope.hasFocus) {
scope.noResults = matches.length === 0;
if (matches.length > 0) {
scope.activeIdx = focusFirst && (inputValue || autoSelectFirst) ? 0 : -1;
scope.matches.length = 0;
//transform labels
for (var i = 0; i < matches.length; i++) {
locals[parserResult.itemName] = matches[i];
scope.matches.push({
id: getMatchId(i),
label: parserResult.viewMapper(scope, locals),
model: matches[i]
});
}
//if (console && console.debug) {
// console.debug("typeahead.unshift.getMatchesAsync scope.matches.length = " + scope.matches.length);
//}
scope.query = inputValue;
// Register the popup repositioning listener
registerPopupPositioning();
// Position the popup
scope.positionPopup();
//if (console && console.debug) {
// console.debug(JSON.stringify(scope.position));
//}
element.attr('aria-expanded', true);
} else {
resetMatches();
}
}
if (onCurrentRequest) {
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;
var scheduleSearchWithTimeout = function (inputValue) {
timeoutPromise = $timeout(function () {
getMatchesAsync(inputValue);
}, waitTime);
};
var cancelPreviousTimeout = function () {
if (timeoutPromise) {
$timeout.cancel(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) {
//if (console && console.debug) {
// console.debug((new Date()) + " $parsers");
// console.debug(modelCtrl.$error);
//}
if (!scope.hasFocus)
return $getModelValue(originalScope);
isValidSelection = false;
//if (console && console.debug) {
// console.debug(inputValue);
//}
if ((inputValue && inputValue.length >= minSearch) || minSearch == 0) {
if (waitTime > 0) {
cancelPreviousTimeout();
scheduleSearchWithTimeout(inputValue);
} else {
getMatchesAsync(inputValue);
}
} else {
isLoadingSetter(originalScope, false);
cancelPreviousTimeout();
resetMatches();
}
if (isEditable) {
return originalModelValue;
} else {
if (!inputValue) {
// Reset in case user had typed something previously.
modelCtrl.$setValidity('editable', true);
return originalModelValue;
} else {
modelCtrl.$setValidity('editable', false);
return undefined;
}
}
});
modelCtrl.$formatters.push(function (modelValue) {
//if (console && console.debug) {
// console.debug((new Date()) + " $formatters");
// console.debug(modelCtrl.$error);
//}
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 = function (activeIdx, refocus) {
//called from within the $digest() cycle
var locals = {};
var model, item;
// *JRR* if (activeIdx >= 0) {
if (activeIdx >= 0) {
locals[parserResult.itemName] = item = scope.matches[activeIdx].model;
model = parserResult.modelMapper(originalScope, locals);
}
var previousValue = originalModelValue;
originalModelValue = model;
$setModelValue(originalScope, model);
modelCtrl.$setDirty();
// *JRR* trigger change listeners
angular.forEach(modelCtrl.$viewChangeListeners, function (listener) {
try {
listener();
} catch (e) {
$exceptionHandler(e);
}
});
onSelectCallback(originalScope, {
$item: item,
$model: model,
$previousModel: previousValue,
$label: parserResult.viewMapper(originalScope, locals)
});
if (!$rootScope.$$phase) $rootScope.$digest();
resetMatches();
isValidSelection = true;
//if (console && console.debug) {
// console.debug("scope.select - activeIdx = " + activeIdx + ", isValidSelection = " + isValidSelection);
//}
if (activeIdx >= 0 && refocus) {
//return focus to the input element if a match was selected via a mouse click event
// use timeout to avoid $rootScope:inprog error
if (displayPopupOnClick) {
scope.preventPopup = true;
}
$timeout(function () { element[0].focus(); }, 0, false);
}
};
//bind keyboard events: arrows up(38) / down(40), enter(13) and tab(9), esc(27)
element.bind('keydown', function (evt) {
//if (console && console.debug) {
// console.debug((new Date()) + " keydown - 1");
// console.debug(modelCtrl.$error);
//}
onKeydownCallback(originalScope, {
$event: evt
});
//typeahead is open and an "interesting" key was pressed
if (scope.matches.length === 0 || HOT_KEYS.indexOf(evt.which) === -1) {
return;
}
// if there's nothing selectable (i.e. focusFirst) and enter is hit, don't do anything
if ((scope.activeIdx == -1) && (evt.which === 13 || evt.which === 9))
return;
// Only allow tab (9) to behave as normal
if (evt.which !== 9)
evt.preventDefault();
//if (console && console.debug) {
// console.debug((new Date()) + " keydown - 2");
// console.debug(modelCtrl.$error);
//}
if (evt.which === 40) {
// down arrow
scope.activeIdx = (scope.activeIdx + 1) % scope.matches.length;
scope.$digest();
} else if (evt.which === 38) {
// up arrow
scope.activeIdx = (scope.activeIdx > 0 ? scope.activeIdx : scope.matches.length) - 1;
scope.$digest();
} else if (evt.which === 13 || evt.which === 9) {
// enter (13) or tab (9)
scope.$apply(function () {
scope.select(scope.activeIdx);
});
} else if (evt.which === 27) {
// esc
evt.stopPropagation();
resetMatches();
scope.$digest();
}
//if (console && console.debug) {
// console.debug((new Date()) + " keydown - 3");
// console.debug(modelCtrl.$error);
//}
});
// *JRR*
element.bind('focus', function () {
scope.hasFocus = true;
isValidSelection = false;
originalModelValue = $getModelValue(originalScope);
if (typeof originalModelValue !== "undefined" && originalModelValue !== null) {
// If there's a value in the model, assume that it's valid
isValidSelection = true;
} else if (defaultValue) {
// If there's no value in the model and a default was supplied set the original values as the default
originalModelValue = defaultValue(originalScope);
}
originalInputValue = getModelDisplay(originalModelValue);
// If the computed Input value is null or undefined, set it to an empty string
if (typeof originalInputValue === "undefined" || originalInputValue === null) {
originalInputValue = '';
}
//if (console && console.debug) {
// console.debug("element.focus - isValidSelection = " + isValidSelection + ", originalInputValue = " + originalInputValue + ", displayPopupOnClick = " + displayPopupOnClick + ", scope.preventPopup = " + scope.preventPopup);
//}
if (displayPopupOnClick) {
if (scope.preventPopup) {
scope.preventPopup = false;
} else {
//modelCtrl.$viewValue = originalInputValue;
getMatchesAsync(modelCtrl.$viewValue);
if (!$rootScope.$$phase) scope.$digest();
}
}
});
element.bind('blur', function (evt) {
scope.hasFocus = false;
//if (console && console.debug) {
// console.debug("element.blur - isValidSelection = " + isValidSelection);
//}
modelCtrl.$setValidity('editable', true);
modelCtrl.$setValidity('parse', true);
// *JRR*
if (requireSelection) {
scope.revertValuePromise = $timeout(function () {
if (isValidSelection) {
var expectedViewValue = getModelDisplay($getModelValue(originalScope));
if (modelCtrl.$viewValue != expectedViewValue) {
modelCtrl.$viewValue = expectedViewValue;
modelCtrl.$render();
}
} else {
if (element[0].value === "" && (typeof defaultValue === "undefined" || defaultValue === null)) {
//if (console && console.debug) {
// console.debug("element.blur - typeahead.select(-1) from element.blur");
//}
if (typeof $getModelValue(originalScope) !== "undefined") {
scope.select(-1);
}
} else {
//if (console && console.debug) {
// console.debug("element.blur - typeahead reset element value from element.blur");
//}
modelCtrl.$setViewValue(originalInputValue);
modelCtrl.$$runValidators(undefined, originalModelValue, originalInputValue, function () { });
modelCtrl.$render();
}
}
resetMatches();
}, 200, true);
}
});
// Keep reference to click handler to unbind it.
var dismissClickHandler = function (evt) {
if (element[0] !== evt.target) {
resetMatches();
if (!$rootScope.$$phase) scope.$digest();
}
};
$document.bind('click', dismissClickHandler);
originalScope.$on('$destroy', function () {
$document.unbind('click', dismissClickHandler);
if (appendToBody) {
$popup.remove();
}
});
var $popup = $compile(popUpEl)(scope);
if (appendToBody) {
$document.find('body').append($popup);
} else {
element.after($popup);
}
}
};
}])
// *JRR*
.directive('typeaheadAdvancedPopup', ['$timeout', '$window', '$document', function ($timeout, $window, $document) {
return {
restrict: 'EA',
scope: {
matches: '=',
hasFocus: '=',
noResults: '=',
query: '=',
active: '=',
position: '=',
positionPopup: '&',
managePopupHeight: '=',
select: '&'
},
replace: true,
templateUrl: 'template/typeahead/typeahead-advanced-popup.html',
link: function (scope, element, attrs) {
scope.templateUrl = attrs.templateUrl;
scope.isOpen = function () {
return scope.matches.length > 0;
};
scope.showNoResults = function() {
return scope.noResults && scope.hasFocus;
};
scope.isActive = function (matchIdx) {
return scope.active == matchIdx;
};
scope.selectActive = function (matchIdx) {
scope.active = matchIdx;
};
scope.selectMatch = function (activeIdx) {
// *JRR*
if (scope.revertValuePromise) {
$timeout.cancel(scope.revertValuePromise);
}
//if (console && console.debug) {
// console.debug("typeaheadPopup.selectMatch(" + activeIdx + ")");
//}
scope.select({ activeIdx: activeIdx, refocus: true });
};
angular.element($window).on("resize", function () {
if (scope.isOpen()) {
scope.positionPopup();
}
});
if (scope.managePopupHeight) {
var overflow = element.css('overflow-x');
angular.element($window).on("resize", function () {
if (scope.isOpen()) {
resizeDropdown();
}
});
scope.$watch("matches.length", function () {
if (scope.isOpen()) {
resizeDropdown();
}
});
}
function resizeDropdown() {
element.css('height', '');
element.css('overflow-x', overflow);
// Putting the resizing into a timeout places it at the end of execution que allowing Angular to display the popup before the following code is executed.
// This works because in JavaScript (unless you are making a call to an outside system) there is only one execution path running at a time.
$timeout(function () {
var os = element[0].getBoundingClientRect();
var pageHeight = $document[0].documentElement.clientHeight;
if (os.top > pageHeight)
return; // off the screen
var diff = pageHeight - os.top - 10;
if (diff < 200) {
diff = 200;
}
if (diff < os.height) {
element.css('height', diff + 'px');
element.css('overflow-x', 'scroll');
}
});
}
}
};
}])
.directive('typeaheadAdvancedMatch', ['$http', '$templateCache', '$compile', '$parse', function ($http, $templateCache, $compile, $parse) {
return {
restrict: 'EA',
scope: {
index: '=',
match: '=',
query: '='
},
link: function (scope, element, attrs) {
var tplUrl = $parse(attrs.templateUrl)(scope.$parent) || 'template/typeahead/typeahead-advanced-match.html';
$http.get(tplUrl, { cache: $templateCache }).success(function (tplContent) {
element.replaceWith($compile(tplContent.trim())(scope));
});
}
};
}])
.filter('typeaheadAdvancedHighlight', function () {
function escapeRegexp(queryToEscape) {
return queryToEscape.replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1');
}
return function (matchItem, query) {
var retVal = query ? ('' + matchItem).replace(new RegExp(escapeRegexp(query), 'gi'), '<strong>$&</strong>') : matchItem;
return retVal;
};
})
.run(["$templateCache", function ($templateCache) {
$templateCache.put("template/typeahead/typeahead-advanced-match.html",
"<a tabindex=\"-1\" bind-html-unsafe2=\"match.label | typeaheadAdvancedHighlight:query\"></a>");
}])
.run(["$templateCache", function ($templateCache) {
$templateCache.put("template/typeahead/typeahead-advanced-popup.html",
"<div>\n" +
"<ul class=\"dropdown-menu\" ng-show=\"showNoResults()\" ng-style=\"{top: position.top+'px', left: position.left+'px'}\" style=\"display: block;\" role=\"listbox\" aria-hidden=\"{{!showNoResults()}}\">\n" +
" <li role=\"option\" id=\"{{match.id}}\">\n" +
" <div class=\"noResults\">No results returned</div>\n" +
" </li>\n" +
"</ul>\n" +
"<ul class=\"dropdown-menu\" ng-show=\"isOpen()\" ng-style=\"{top: position.top+'px', left: position.left+'px'}\" style=\"display: block;\" role=\"listbox\" aria-hidden=\"{{!isOpen()}}\">\n" +
" <li ng-repeat=\"match in matches track by $index\" ng-class=\"{active: isActive($index) }\" ng-mouseenter=\"selectActive($index)\" ng-click=\"selectMatch($index)\" role=\"option\" id=\"{{match.id}}\">\n" +
" <div typeahead-advanced-match index=\"$index\" match=\"match\" query=\"query\" template-url=\"templateUrl\" manage-popup-height=\"managePopupHeight\"></div>\n" +
" </li>\n" +
"</ul>\n" +
"</div>"+
"");
}]);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment