angular.module('MassAutoComplete', [])
.directive('massAutocomplete', ["$timeout", "$window", "$document", "$q", function ($timeout, $window, $document, $q) {
'use strict';
return {
restrict: "A",
scope: { options: '&massAutocomplete' },
transclude: true,
template:
'' +
'
' +
'' +
'
',
link: function (scope, element) {
scope.container = angular.element(element[0].getElementsByClassName('ac-container')[0]);
},
controller: ["$scope", function ($scope) {
var that = this;
var KEYS = {
TAB: 9,
ESC: 27,
ENTER: 13,
UP: 38,
DOWN: 40
};
var EVENTS = {
KEYDOWN: 'keydown',
RESIZE: 'resize',
BLUR: 'blur'
};
var bound_events = {};
bound_events[EVENTS.BLUR] = null;
bound_events[EVENTS.KEYDOWN] = null;
bound_events[EVENTS.RESIZE] = null;
var _user_options = $scope.options() || {};
var user_options = {
debounce_position: _user_options.debounce_position || 150,
debounce_attach: _user_options.debounce_attach || 300,
debounce_suggest: _user_options.debounce_suggest || 200,
debounce_blur: _user_options.debounce_blur || 150
};
var current_element,
current_model,
current_options,
previous_value,
value_watch,
last_selected_value;
$scope.show_autocomplete = false;
// Debounce - taken from underscore
function debounce(func, wait, immediate) {
var timeout;
return function() {
var context = this, args = arguments;
var later = function() {
timeout = null;
if (!immediate) func.apply(context, args);
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
}
function _position_autocomplete() {
var rect = current_element[0].getBoundingClientRect(),
scrollTop = $document[0].body.scrollTop || $document[0].documentElement.scrollTop || $window.pageYOffset,
scrollLeft = $document[0].body.scrollLeft || $document[0].documentElement.scrollLeft || $window.pageXOffset,
container = $scope.container[0];
container.style.top = rect.top + rect.height + scrollTop + 'px';
container.style.left = rect.left + scrollLeft + 'px';
container.style.width = rect.width + 'px';
}
var position_autocomplete = debounce(_position_autocomplete, user_options.debounce_position);
// Attach autocomplete behaviour to an input element.
function _attach(ngmodel, target_element, options) {
// Element is already attached.
if (current_element === target_element) return;
// Safe: clear previously attached elements.
if (current_element) that.detach();
// The element is still the active element.
if (target_element[0] !== $document[0].activeElement) return;
options.on_attach && options.on_attach();
current_element = target_element;
current_model = ngmodel;
current_options = options;
previous_value = ngmodel.$viewValue;
$scope.results = [];
$scope.selected_index = -1;
bind_element();
value_watch = $scope.$watch(
function () {
return ngmodel.$modelValue;
},
function (nv, ov) {
// Prevent suggestion cycle when the value is the last value selected.
// When selecting from the menu the ng-model is updated and this watch
// is triggered. This causes another suggestion cycle that will provide as
// suggestion the value that is currently selected - this is unnecessary.
if (nv === last_selected_value)
return;
_position_autocomplete();
suggest(nv, current_element);
}
);
}
that.attach = debounce(_attach, user_options.debounce_attach);
function _suggest(term, target_element) {
$scope.selected_index = 0;
$scope.waiting_for_suggestion = true;
if (typeof(term) === 'string' && term.length > 0) {
$q.when(current_options.suggest(term),
function suggest_succeeded(suggestions) {
// Make sure the suggestion we are processing is of the current element.
// When using remote sources for example, a suggestion cycnle might be
// triggered at a later time (When a different field is in focus).
if (!current_element || current_element !== target_element)
return;
if (suggestions && suggestions.length > 0) {
// Add the original term as the first value to enable the user
// to return to his original expression after suggestions were made.
$scope.results = [{ value: term, label: ''}].concat(suggestions);
$scope.show_autocomplete = true;
if (current_options.auto_select_first)
set_selection(1);
} else {
$scope.results = [];
}
},
function suggest_failed(error) {
$scope.show_autocomplete = false;
current_options.on_error && current_options.on_error(error);
}
).finally(function suggest_finally() {
$scope.waiting_for_suggestion = false;
});
} else {
$scope.waiting_for_suggestion = false;
$scope.show_autocomplete = false;
$scope.$apply();
}
}
var suggest = debounce(_suggest, user_options.debounce_suggest);
// Trigger end of editing and remove all attachments made by
// this directive to the input element.
that.detach = function () {
if (current_element) {
var value = current_element.val();
update_model_value(value);
current_options.on_detach && current_options.on_detach(value);
current_element.unbind(EVENTS.KEYDOWN, bound_events[EVENTS.KEYDOWN]);
current_element.unbind(EVENTS.BLUR, bound_events[EVENTS.BLUR]);
}
// Clear references and events.
$scope.show_autocomplete = false;
angular.element($window).unbind(EVENTS.RESIZE, bound_events[EVENTS.RESIZE]);
value_watch && value_watch();
$scope.selected_index = $scope.results = undefined;
current_model = current_element = previous_value = undefined;
};
// Update angular's model view value.
// It is important that before triggering hooks the model's view
// value will be synced with the visible value to the user. This will
// allow the consumer controller to rely on its local ng-model.
function update_model_value(value) {
if (current_model.$modelValue !== value) {
current_model.$setViewValue(value);
current_model.$render();
}
}
// Set the current selection while navigating through the menu.
function set_selection(i) {
// We use value instead of setting the model's view value
// because we watch the model value and setting it will trigger
// a new suggestion cycle.
var selected = $scope.results[i];
current_element.val(selected.value);
$scope.selected_index = i;
return selected;
}
// Apply and accept the current selection made from the menu.
// When selecting from the menu directly (using click or touch) the
// selection is directly applied.
$scope.apply_selection = function (i) {
current_element[0].focus();
if (!$scope.show_autocomplete || i > $scope.results.length || i < 0)
return;
var selected = set_selection(i);
last_selected_value = selected.value;
update_model_value(selected.value);
$scope.show_autocomplete = false;
current_options.on_select && current_options.on_select(selected);
};
function bind_element() {
angular.element($window).bind(EVENTS.RESIZE, position_autocomplete);
bound_events[EVENTS.BLUR] = function () {
// Detach the element from the auto complete when input loses focus.
// Focus is lost when a selection is made from the auto complete menu
// using the mouse (or touch). In that case we don't want to detach so
// we wait several ms for the input to regain focus.
$timeout(function() {
if (!current_element || current_element[0] !== $document[0].activeElement)
that.detach();
}, user_options.debounce_blur);
};
current_element.bind(EVENTS.BLUR, bound_events[EVENTS.BLUR]);
bound_events[EVENTS.KEYDOWN] = function (e) {
// Reserve key combinations with shift for different purposes.
if (e.shiftKey) return;
switch (e.keyCode) {
// Close the menu if it's open. Or, undo changes made to the value
// if the menu is closed.
case KEYS.ESC:
if ($scope.show_autocomplete) {
$scope.show_autocomplete = false;
$scope.$apply();
} else {
current_element.val(previous_value);
}
break;
// Select an element and close the menu. Or, if a selection is
// unavailable let the event propagate.
case KEYS.ENTER:
// Accept a selection only if results exist, the menu is
// displayed and the results are valid (no current request
// for new suggestions is active).
if ($scope.show_autocomplete &&
$scope.selected_index > 0 &&
!$scope.waiting_for_suggestion) {
$scope.apply_selection($scope.selected_index);
// When selecting an item from the AC list the focus is set on
// the input element. So the enter will cause a keypress event
// on the input itself. Since this enter is not intended for the
// input but for the AC result we prevent propagation to parent
// elements because this event is not of their concern. We cannot
// prevent events from firing when the event was registered on
// the input itself.
e.stopPropagation();
e.preventDefault();
}
$scope.show_autocomplete = false;
$scope.$apply();
break;
// Navigate the menu when it's open. When it's not open fall back
// to default behavior.
case KEYS.TAB:
if (!$scope.show_autocomplete)
break;
e.preventDefault();
/* falls through */
// Open the menu when results exists but are not displayed. Or,
// select the next element when the menu is open. When reaching
// bottom wrap to top.
case KEYS.DOWN:
if ($scope.results.length > 0) {
if ($scope.show_autocomplete) {
set_selection($scope.selected_index + 1 > $scope.results.length - 1 ? 0 : $scope.selected_index + 1);
} else {
$scope.show_autocomplete = true;
$scope.selected_index = 0;
}
$scope.$apply();
}
break;
// Navigate up in the menu. When reaching the top wrap to bottom.
case KEYS.UP:
if ($scope.show_autocomplete) {
e.preventDefault();
set_selection($scope.selected_index - 1 >= 0 ? $scope.selected_index - 1 : $scope.results.length - 1);
$scope.$apply();
}
break;
}
};
current_element.bind(EVENTS.KEYDOWN, bound_events[EVENTS.KEYDOWN]);
}
$scope.$on('$destroy', function () {
that.detach();
$scope.container.remove();
});
}]
};
}])
.directive('massAutocompleteItem', function () {
'use strict';
return {
restrict: "A",
require: ["^massAutocomplete", "ngModel"],
scope: {'massAutocompleteItem' : "&"},
link: function (scope, element, attrs, required) {
// Prevent html5/browser auto completion.
attrs.$set('autocomplete', 'off');
element.bind('focus', function () {
var options = scope.massAutocompleteItem();
if (!options)
throw "Invalid options";
required[0].attach(required[1], element, options);
});
}
};
});