diff --git a/HISTORY.md b/HISTORY.md index f923297..ac56973 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,12 @@ MassAutocomplete ================ +## v0.6.0 +- Set `position:absolute` in directive, so it won't trigger a CSP error (PR #67, Thanks @thany!) + +## v0.5.0 +- Fix debounce function to use $timeout instead of setTimeout (PR #61, Thanks @OoDeLally!) + ### v0.4.0 - Lint & style changes. - (Breaking change) Renamed MassautoCompleteConfigurrerProvider -> massAutocompleteConfig to match directive name. diff --git a/bower.json b/bower.json index 1e5b65d..3b9d468 100644 --- a/bower.json +++ b/bower.json @@ -1,6 +1,6 @@ { "name": "angular-mass-autocomplete", - "version": "0.4.0", + "version": "0.6.0", "main": "massautocomplete.min.js", "dependencies": { "angular": ">=1.2.0" diff --git a/massautocomplete.js b/massautocomplete.js index 4ea2627..6fb77c2 100644 --- a/massautocomplete.js +++ b/massautocomplete.js @@ -1,461 +1,503 @@ /* global angular */ -(function() { -'use strict'; - -angular.module('MassAutoComplete', []) - -.provider('massAutocompleteConfig', function() { - var config = this; - - config.KEYS = { - TAB: 9, - ESC: 27, - ENTER: 13, - UP: 38, - DOWN: 40 - }; - - config.EVENTS = { - KEYDOWN: 'keydown', - RESIZE: 'resize', - BLUR: 'blur' - }; - - config.DEBOUNCE = { - position: 150, - attach: 300, - suggest: 200, - blur: 150 - }; - - config.generate_random_id = function(prefix) { - return prefix + '_' + Math.random().toString().substring(2); - }; - - // Position ac container given a target element - config.position_autocomplete = function(container, target) { - var rect = target[0].getBoundingClientRect(), - scrollTop = document.body.scrollTop || document.documentElement.scrollTop || window.pageYOffset, - scrollLeft = document.body.scrollLeft || document.documentElement.scrollLeft || window.pageXOffset; - - container[0].style.top = rect.top + rect.height + scrollTop + 'px'; - container[0].style.left = rect.left + scrollLeft + 'px'; - container[0].style.width = rect.width + 'px'; - }; - - this.$get = function() { - return config; - }; -}) - -.directive('massAutocomplete', ['massAutocompleteConfig', '$timeout', '$window', '$document', '$q', function(config, $timeout, $window, $document, $q) { - return { - restrict: 'A', - scope: { - options: '&massAutocomplete' - }, - transclude: true, - template: - '' + - - '
' + - - '' + +(function () { + 'use strict'; - '
', + angular.module('MassAutoComplete', []) - link: function(scope, element) { - scope.container = angular.element(element[0].getElementsByClassName('ac-container')[0]); - }, + .provider('massAutocompleteConfig', function () { + var config = this; - controller: ['$scope', function($scope) { - var that = this; + config.KEYS = { + TAB: 9, + ESC: 27, + ENTER: 13, + UP: 38, + DOWN: 40 + }; - var bound_events = {}; - bound_events[config.EVENTS.BLUR] = null; - bound_events[config.EVENTS.KEYDOWN] = null; - bound_events[config.EVENTS.RESIZE] = null; + config.EVENTS = { + KEYDOWN: 'keydown', + RESIZE: 'resize', + BLUR: 'blur' + }; - var _user_options = $scope.options() || {}; - var user_options = { - debounce_position: _user_options.debounce_position || config.DEBOUNCE.position, - debounce_attach: _user_options.debounce_attach || config.DEBOUNCE.attach, - debounce_suggest: _user_options.debounce_suggest || config.DEBOUNCE.suggest, - debounce_blur: _user_options.debounce_blur || config.DEBOUNCE.blur + config.DEBOUNCE = { + position: 150, + attach: 300, + suggest: 200, + blur: 150 }; - var current_element, - current_model, - current_options, - previous_value, - value_watch, - last_selected_value, - current_element_random_id_set; - - $scope.show_autocomplete = false; - - function show_autocomplete() { - $scope.show_autocomplete = true; - } - - function hide_autocomplete() { - $scope.show_autocomplete = false; - clear_selection(); - } - - // 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); - } + config.generate_random_id = function (prefix) { + return prefix + '_' + Math.random().toString().substring(2); + }; + + // Position ac container given a target element + config.position_autocomplete = function (container, target) { + var rect = target[0].getBoundingClientRect(); + + container[0].style.top = rect.height + 'px'; + container[0].style.width = rect.width + 'px'; + }; + + config.CLASSES = { + container: 'ac-container', + menu: 'ac-menu', + menu_item: 'ac-menu-item', + menu_item_focus: 'ac-state-focus' + }; + + this.$get = function () { + return config; + }; + }) + + .directive('massAutocomplete', ['massAutocompleteConfig', '$timeout', '$window', '$document', '$q', function (config, $timeout, $window, $document, $q) { + return { + restrict: 'A', + scope: { + options: '&massAutocomplete' + }, + transclude: true, + template: '' + + + '
' + + + '' + + + '
', + + link: function (scope, element) { + element.addClass('mass-autocomplete'); + scope.container = angular.element(element[0].getElementsByClassName('ac-container')[0]); + scope.container[0].style.position = 'absolute'; + }, + + controller: ['$scope', function ($scope) { + var that = this; + + var bound_events = {}; + bound_events[config.EVENTS.BLUR] = null; + bound_events[config.EVENTS.KEYDOWN] = null; + bound_events[config.EVENTS.RESIZE] = null; + + var _user_options = $scope.options() || {}; + var user_options = { + debounce_position: _user_options.debounce_position || config.DEBOUNCE.position, + debounce_attach: _user_options.debounce_attach || config.DEBOUNCE.attach, + debounce_suggest: _user_options.debounce_suggest || config.DEBOUNCE.suggest, + debounce_blur: _user_options.debounce_blur || config.DEBOUNCE.blur }; - var callNow = immediate && !timeout; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - if (callNow) { - func.apply(context, args); + + var current_element, + current_model, + current_options, + previous_value, + value_watch, + last_selected_value, + current_element_random_id_set; + + $scope.show_autocomplete = false; + + function show_autocomplete() { + $scope.show_autocomplete = true; } - }; - } - - // Make sure an element has id. - // Return true if id was generated. - function ensure_element_id(element) { - if (!element.id || element.id === '') { - element.id = config.generate_random_id('ac_element'); - return true; - } - return false; - } - - function _position_autocomplete() { - config.position_autocomplete($scope.container, current_element); - } - var position_autocomplete = debounce(_position_autocomplete, user_options.debounce_position); - - 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 cycle might be - // triggered at a later time (When a different field is in focus). - if (!current_element || current_element !== target_element) { - return; + + function hide_autocomplete() { + $scope.show_autocomplete = false; + clear_selection(); + } + + // Debounce - taken from underscore. + function debounce(func, wait, immediate) { + var timeoutPromise; + return function () { + var context = this, + args = arguments; + var later = function () { + timeoutPromise = null; + if (!immediate) { + func.apply(context, args); + } + }; + var callNow = immediate && !timeoutPromise; + $timeout.cancel(timeoutPromise); + timeoutPromise = $timeout(later, wait); + if (callNow) { + func.apply(context, args); } + }; + } - if (suggestions && suggestions.length > 0) { - // Set unique id to each suggestion so we can - // reference them (aria) - suggestions.forEach(function(s) { - if (!s.id) { - s.id = config.generate_random_id('ac_item'); + // Make sure an element has id. + // Return true if id was generated. + function ensure_element_id(element) { + if (!element.id || element.id === '') { + element.id = config.generate_random_id('ac_element'); + return true; + } + return false; + } + + function _position_autocomplete() { + config.position_autocomplete($scope.container, current_element); + } + var position_autocomplete = debounce(_position_autocomplete, user_options.debounce_position); + + 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 cycle 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) { + // Set unique id to each suggestion so we can + // reference them (aria) + suggestions.forEach(function (s) { + if (!s.id) { + s.id = config.generate_random_id('ac_item'); + } + }); + // 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: '', + id: '' + }].concat(suggestions); + show_autocomplete(); + if (current_options.auto_select_first) { + set_selection(1); + } + } else { + $scope.results = []; + hide_autocomplete(); + } + }, + function suggest_failed(error) { + hide_autocomplete(); + if (current_options.on_error) { + current_options.on_error(error); } - }); - // 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: '', id: ''}].concat(suggestions); - show_autocomplete(); - if (current_options.auto_select_first) { - set_selection(1); } - } else { - $scope.results = []; - hide_autocomplete(); - } - }, - function suggest_failed(error) { + ).finally(function suggest_finally() { + $scope.waiting_for_suggestion = false; + }); + } else { + $scope.waiting_for_suggestion = false; hide_autocomplete(); - if (current_options.on_error) { - current_options.on_error(error); - } + $scope.$apply(); } - ).finally(function suggest_finally() { - $scope.waiting_for_suggestion = false; - }); - } else { - $scope.waiting_for_suggestion = false; - hide_autocomplete(); - $scope.$apply(); - } - } - var suggest = debounce(_suggest, user_options.debounce_suggest); - - // Attach autocomplete behavior 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(); - } + } + var suggest = debounce(_suggest, user_options.debounce_suggest); - // The element is still the active element. - if (target_element[0] !== $document[0].activeElement) { - return; - } + // Attach autocomplete behavior to an input element. + function _attach(ngmodel, target_element, options) { + // Element is already attached. + if (current_element === target_element) { + return; + } - if (options.on_attach) { - options.on_attach(); - } + // Safe: clear previously attached elements. + if (current_element) { + that.detach(); + } - current_element = target_element; - current_model = ngmodel; - current_options = options; - previous_value = ngmodel.$viewValue; - current_element_random_id_set = ensure_element_id(target_element); - $scope.container[0].setAttribute('aria-labelledby', current_element.id); - - $scope.results = []; - $scope.selected_index = -1; - bind_element(); - - value_watch = $scope.$watch( - function() { - return ngmodel.$modelValue; - }, - function(nv) { - // 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) { + // The element is still the active element. + if (target_element[0] !== $document[0].activeElement) { return; } - _position_autocomplete(); - suggest(nv, current_element); + if (options.on_attach) { + options.on_attach(); + } + + current_element = target_element; + current_model = ngmodel; + current_options = options; + previous_value = ngmodel.$viewValue; + current_element_random_id_set = ensure_element_id(target_element); + $scope.container[0].setAttribute('aria-labelledby', current_element.id); + + $scope.results = []; + $scope.selected_index = -1; + bind_element(); + + value_watch = $scope.$watch( + function () { + return ngmodel.$modelValue; + }, + function (nv) { + // 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); - - // 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); - if (current_options.on_detach) { - current_options.on_detach(value); + that.attach = debounce(_attach, user_options.debounce_attach); + + // 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); + if (current_options.on_detach) { + current_options.on_detach(value); + } + current_element.unbind(config.EVENTS.KEYDOWN, bound_events[config.EVENTS.KEYDOWN]); + current_element.unbind(config.EVENTS.BLUR, bound_events[config.EVENTS.BLUR]); + if (current_element_random_id_set) { + current_element[0].removeAttribute('id'); + } + } + hide_autocomplete(); + $scope.container[0].removeAttribute('aria-labelledby'); + // Clear references and config.events. + angular.element($window).unbind(config.EVENTS.RESIZE, bound_events[config.EVENTS.RESIZE]); + if (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(); + } } - current_element.unbind(config.EVENTS.KEYDOWN, bound_events[config.EVENTS.KEYDOWN]); - current_element.unbind(config.EVENTS.BLUR, bound_events[config.EVENTS.BLUR]); - if (current_element_random_id_set) { - current_element[0].removeAttribute('id'); + + function clear_selection() { + $scope.selected_index = -1; + $scope.container[0].removeAttribute('aria-activedescendant'); } - } - hide_autocomplete(); - $scope.container[0].removeAttribute('aria-labelledby'); - // Clear references and config.events. - angular.element($window).unbind(config.EVENTS.RESIZE, bound_events[config.EVENTS.RESIZE]); - if (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(); - } - } - - function clear_selection() { - $scope.selected_index = -1; - $scope.container[0].removeAttribute('aria-activedescendant'); - } - - // 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; - $scope.container[0].setAttribute('aria-activedescendant', selected.id); - 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; - } + // Set the scroll on selected position when keypress + function set_scroll(i) { + // only if container has scroll + if (angular.element($scope.container).get(0).scrollHeight > angular.element($scope.container).height()) { + var selected = $scope.results[i]; - var selected = set_selection(i); - last_selected_value = selected.value; - update_model_value(selected.value); - hide_autocomplete(); + if (selected.id !== '') { + var current_id_el = angular.element('#' + selected.id)[0]; + angular.element($scope.container).scrollTop(current_id_el.offsetTop); + } + } + } - if (current_options.on_select) { - current_options.on_select(selected); - } - }; + // 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; + $scope.container[0].setAttribute('aria-activedescendant', selected.id); + return selected; + } - function bind_element() { - angular.element($window).bind(config.EVENTS.RESIZE, position_autocomplete); + $scope.apply_keyboard_selection = function (keyCode) { + if (keyCode == 13) { // press enter + var selected = set_selection(1); + last_selected_value = selected.value; + update_model_value(selected.value); + hide_autocomplete(); - bound_events[config.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(); + if (current_options.on_select) { + current_options.on_select(selected); + } } - }, user_options.debounce_blur); - }; - current_element.bind(config.EVENTS.BLUR, bound_events[config.EVENTS.BLUR]); - - bound_events[config.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 config.KEYS.ESC: - if ($scope.show_autocomplete) { - hide_autocomplete(); - $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 config.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(); - } + // 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; + } - hide_autocomplete(); - $scope.$apply(); - break; + var selected = set_selection(i); + last_selected_value = selected.value; + update_model_value(selected.value); + hide_autocomplete(); - // Navigate the menu when it's open. When it's not open fall back - // to default behavior. - case config.KEYS.TAB: - if (!$scope.show_autocomplete) { - break; - } + if (current_options.on_select) { + current_options.on_select(selected); + } + }; - e.preventDefault(); - - // 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. - /* falls through */ - case config.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 { - show_autocomplete(); - set_selection(0); + function bind_element() { + angular.element($window).bind(config.EVENTS.RESIZE, position_autocomplete); + + bound_events[config.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(); } - $scope.$apply(); + }, user_options.debounce_blur); + }; + current_element.bind(config.EVENTS.BLUR, bound_events[config.EVENTS.BLUR]); + + bound_events[config.EVENTS.KEYDOWN] = function (e) { + // Reserve key combinations with shift for different purposes. + if (e.shiftKey) { + return; } - break; - - // Navigate up in the menu. When reaching the top wrap to bottom. - case config.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(); + + var position; + + switch (e.keyCode) { + // Close the menu if it's open. Or, undo changes made to the value + // if the menu is closed. + case config.KEYS.ESC: + if ($scope.show_autocomplete) { + hide_autocomplete(); + $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 config.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(); + } + + hide_autocomplete(); + $scope.$apply(); + break; + + // Navigate the menu when it's open. When it's not open fall back + // to default behavior. + case config.KEYS.TAB: + if (!$scope.show_autocomplete) { + break; + } + + e.preventDefault(); + + // 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. + /* falls through */ + case config.KEYS.DOWN: + if ($scope.results.length > 0) { + if ($scope.show_autocomplete) { + position = $scope.selected_index + 1 > $scope.results.length - 1 ? 0 : $scope.selected_index + 1; + set_selection(position); + set_scroll(position); + } else { + show_autocomplete(); + set_selection(0); + } + $scope.$apply(); + } + break; + + // Navigate up in the menu. When reaching the top wrap to bottom. + case config.KEYS.UP: + if ($scope.show_autocomplete) { + e.preventDefault(); + position = $scope.selected_index - 1 >= 0 ? $scope.selected_index - 1 : $scope.results.length - 1; + set_selection(position); + set_scroll(position); + $scope.$apply(); + } + break; } - break; + }; + current_element.bind(config.EVENTS.KEYDOWN, bound_events[config.EVENTS.KEYDOWN]); } - }; - current_element.bind(config.EVENTS.KEYDOWN, bound_events[config.EVENTS.KEYDOWN]); - } - - $scope.$on('$destroy', function() { - that.detach(); - $scope.container.remove(); - }); - }] - }; -}]) - -.directive('massAutocompleteItem', function() { - return { - restrict: 'A', - require: [ - '^massAutocomplete', - 'ngModel' - ], - scope: { - 'massAutocompleteItem' : '&' - }, - link: function(scope, element, attrs, required) { - // Prevent html5/browser auto completion. - attrs.$set('autocomplete', 'off'); - - var acContainer = required[0]; - var ngModel = required[1]; - - element.bind('focus', function() { - var options = scope.massAutocompleteItem(); - if (!options) { - throw new Error('Invalid options'); + + $scope.$on('$destroy', function () { + that.detach(); + $scope.container.remove(); + }); + }] + }; + }]) + + .directive('massAutocompleteItem', function () { + return { + restrict: 'A', + require: [ + '^massAutocomplete', + 'ngModel' + ], + scope: { + 'massAutocompleteItem': '&' + }, + link: function (scope, element, attrs, required) { + // Prevent html5/browser auto completion. + attrs.$set('autocomplete', 'off'); + + var acContainer = required[0]; + var ngModel = required[1]; + + element.bind('focus', function () { + var options = scope.massAutocompleteItem(); + if (!options) { + throw new Error('Invalid options'); + } + acContainer.attach(ngModel, element, options); + }); } - acContainer.attach(ngModel, element, options); - }); - } - }; -}); -})(); + }; + }); +})(); \ No newline at end of file diff --git a/massautocomplete.min.js b/massautocomplete.min.js index 702b8d9..4e97e63 100644 --- a/massautocomplete.min.js +++ b/massautocomplete.min.js @@ -1 +1 @@ -!function(){"use strict";angular.module("MassAutoComplete",[]).provider("massAutocompleteConfig",function(){var e=this;e.KEYS={TAB:9,ESC:27,ENTER:13,UP:38,DOWN:40},e.EVENTS={KEYDOWN:"keydown",RESIZE:"resize",BLUR:"blur"},e.DEBOUNCE={position:150,attach:300,suggest:200,blur:150},e.generate_random_id=function(e){return e+"_"+Math.random().toString().substring(2)},e.position_autocomplete=function(e,t){var n=t[0].getBoundingClientRect(),o=document.body.scrollTop||document.documentElement.scrollTop||window.pageYOffset,i=document.body.scrollLeft||document.documentElement.scrollLeft||window.pageXOffset;e[0].style.top=n.top+n.height+o+"px",e[0].style.left=n.left+i+"px",e[0].style.width=n.width+"px"},this.$get=function(){return e}}).directive("massAutocomplete",["massAutocompleteConfig","$timeout","$window","$document","$q",function(e,t,n,o,i){return{restrict:"A",scope:{options:"&massAutocomplete"},transclude:!0,template:'
',link:function(e,t){e.container=angular.element(t[0].getElementsByClassName("ac-container")[0])},controller:["$scope",function(a){function c(){a.show_autocomplete=!0}function l(){a.show_autocomplete=!1,m()}function s(e,t,n){var o;return function(){var i=this,a=arguments,c=function(){o=null,n||e.apply(i,a)},l=n&&!o;clearTimeout(o),o=setTimeout(c,t),l&&e.apply(i,a)}}function u(t){return(!t.id||""===t.id)&&(t.id=e.generate_random_id("ac_element"),!0)}function r(){e.position_autocomplete(a.container,b)}function d(t,n){a.selected_index=0,a.waiting_for_suggestion=!0,"string"==typeof t&&t.length>0?i.when(N.suggest(t),function(o){b&&b===n&&(o&&o.length>0?(o.forEach(function(t){t.id||(t.id=e.generate_random_id("ac_item"))}),a.results=[{value:t,label:"",id:""}].concat(o),c(),N.auto_select_first&&f(1)):(a.results=[],l()))},function(e){l(),N.on_error&&N.on_error(e)})["finally"](function(){a.waiting_for_suggestion=!1}):(a.waiting_for_suggestion=!1,l(),a.$apply())}function E(e,t,n){b!==t&&(b&&g.detach(),t[0]===o[0].activeElement&&(n.on_attach&&n.on_attach(),b=t,v=e,N=n,w=e.$viewValue,T=u(t),a.container[0].setAttribute("aria-labelledby",b.id),a.results=[],a.selected_index=-1,_(),S=a.$watch(function(){return e.$modelValue},function(e){e!==y&&(r(),A(e,b))})))}function p(e){v.$modelValue!==e&&(v.$setViewValue(e),v.$render())}function m(){a.selected_index=-1,a.container[0].removeAttribute("aria-activedescendant")}function f(e){var t=a.results[e];return b.val(t.value),a.selected_index=e,a.container[0].setAttribute("aria-activedescendant",t.id),t}function _(){angular.element(n).bind(e.EVENTS.RESIZE,x),h[e.EVENTS.BLUR]=function(){t(function(){b&&b[0]===o[0].activeElement||g.detach()},V.debounce_blur)},b.bind(e.EVENTS.BLUR,h[e.EVENTS.BLUR]),h[e.EVENTS.KEYDOWN]=function(t){if(!t.shiftKey)switch(t.keyCode){case e.KEYS.ESC:a.show_autocomplete?(l(),a.$apply()):b.val(w);break;case e.KEYS.ENTER:a.show_autocomplete&&a.selected_index>0&&!a.waiting_for_suggestion&&(a.apply_selection(a.selected_index),t.stopPropagation(),t.preventDefault()),l(),a.$apply();break;case e.KEYS.TAB:if(!a.show_autocomplete)break;t.preventDefault();case e.KEYS.DOWN:a.results.length>0&&(a.show_autocomplete?f(a.selected_index+1>a.results.length-1?0:a.selected_index+1):(c(),f(0)),a.$apply());break;case e.KEYS.UP:a.show_autocomplete&&(t.preventDefault(),f(a.selected_index-1>=0?a.selected_index-1:a.results.length-1),a.$apply())}},b.bind(e.EVENTS.KEYDOWN,h[e.EVENTS.KEYDOWN])}var g=this,h={};h[e.EVENTS.BLUR]=null,h[e.EVENTS.KEYDOWN]=null,h[e.EVENTS.RESIZE]=null;var b,v,N,w,S,y,T,$=a.options()||{},V={debounce_position:$.debounce_position||e.DEBOUNCE.position,debounce_attach:$.debounce_attach||e.DEBOUNCE.attach,debounce_suggest:$.debounce_suggest||e.DEBOUNCE.suggest,debounce_blur:$.debounce_blur||e.DEBOUNCE.blur};a.show_autocomplete=!1;var x=s(r,V.debounce_position),A=s(d,V.debounce_suggest);g.attach=s(E,V.debounce_attach),g.detach=function(){if(b){var t=b.val();p(t),N.on_detach&&N.on_detach(t),b.unbind(e.EVENTS.KEYDOWN,h[e.EVENTS.KEYDOWN]),b.unbind(e.EVENTS.BLUR,h[e.EVENTS.BLUR]),T&&b[0].removeAttribute("id")}l(),a.container[0].removeAttribute("aria-labelledby"),angular.element(n).unbind(e.EVENTS.RESIZE,h[e.EVENTS.RESIZE]),S&&S(),a.selected_index=a.results=void 0,v=b=w=void 0},a.apply_selection=function(e){if(b[0].focus(),!(!a.show_autocomplete||e>a.results.length||e<0)){var t=f(e);y=t.value,p(t.value),l(),N.on_select&&N.on_select(t)}},a.$on("$destroy",function(){g.detach(),a.container.remove()})}]}}]).directive("massAutocompleteItem",function(){return{restrict:"A",require:["^massAutocomplete","ngModel"],scope:{massAutocompleteItem:"&"},link:function(e,t,n,o){n.$set("autocomplete","off");var i=o[0],a=o[1];t.bind("focus",function(){var n=e.massAutocompleteItem();if(!n)throw new Error("Invalid options");i.attach(a,t,n)})}}})}(); \ No newline at end of file +!function(){"use strict";angular.module("MassAutoComplete",[]).provider("massAutocompleteConfig",function(){var e=this;e.KEYS={TAB:9,ESC:27,ENTER:13,UP:38,DOWN:40},e.EVENTS={KEYDOWN:"keydown",RESIZE:"resize",BLUR:"blur"},e.DEBOUNCE={position:150,attach:300,suggest:200,blur:150},e.generate_random_id=function(e){return e+"_"+Math.random().toString().substring(2)},e.position_autocomplete=function(e,t){var n=t[0].getBoundingClientRect();e[0].style.top=n.height+"px",e[0].style.width=n.width+"px"},e.CLASSES={container:"ac-container",menu:"ac-menu",menu_item:"ac-menu-item",menu_item_focus:"ac-state-focus"},this.$get=function(){return e}}).directive("massAutocomplete",["massAutocompleteConfig","$timeout","$window","$document","$q",function(e,t,n,o,i){return{restrict:"A",scope:{options:"&massAutocomplete"},transclude:!0,template:'
',link:function(e,t){t.addClass("mass-autocomplete"),e.container=angular.element(t[0].getElementsByClassName("ac-container")[0]),e.container[0].style.position="absolute"},controller:["$scope",function(a){function l(){a.show_autocomplete=!0}function c(){a.show_autocomplete=!1,_()}function s(e,n,o){var i;return function(){var a=this,l=arguments,c=function(){i=null,o||e.apply(a,l)},s=o&&!i;t.cancel(i),i=t(c,n),s&&e.apply(a,l)}}function u(t){return(!t.id||""===t.id)&&(t.id=e.generate_random_id("ac_element"),!0)}function r(){e.position_autocomplete(a.container,v)}function d(t,n){a.selected_index=0,a.waiting_for_suggestion=!0,"string"==typeof t&&t.length>0?i.when(N.suggest(t),function(o){v&&v===n&&(o&&o.length>0?(o.forEach(function(t){t.id||(t.id=e.generate_random_id("ac_item"))}),a.results=[{value:t,label:"",id:""}].concat(o),l(),N.auto_select_first&&f(1)):(a.results=[],c()))},function(e){c(),N.on_error&&N.on_error(e)})["finally"](function(){a.waiting_for_suggestion=!1}):(a.waiting_for_suggestion=!1,c(),a.$apply())}function E(e,t,n){v!==t&&(v&&h.detach(),t[0]===o[0].activeElement&&(n.on_attach&&n.on_attach(),v=t,S=e,N=n,y=e.$viewValue,A=u(t),a.container[0].setAttribute("aria-labelledby",v.id),a.results=[],a.selected_index=-1,g(),w=a.$watch(function(){return e.$modelValue},function(e){e!==$&&(r(),x(e,v))})))}function p(e){S.$modelValue!==e&&(S.$setViewValue(e),S.$render())}function _(){a.selected_index=-1,a.container[0].removeAttribute("aria-activedescendant")}function m(e){if(angular.element(a.container).get(0).scrollHeight>angular.element(a.container).height()){var t=a.results[e];if(""!==t.id){var n=angular.element("#"+t.id)[0];angular.element(a.container).scrollTop(n.offsetTop)}}}function f(e){var t=a.results[e];return v.val(t.value),a.selected_index=e,a.container[0].setAttribute("aria-activedescendant",t.id),t}function g(){angular.element(n).bind(e.EVENTS.RESIZE,C),b[e.EVENTS.BLUR]=function(){t(function(){v&&v[0]===o[0].activeElement||h.detach()},V.debounce_blur)},v.bind(e.EVENTS.BLUR,b[e.EVENTS.BLUR]),b[e.EVENTS.KEYDOWN]=function(t){if(!t.shiftKey){var n;switch(t.keyCode){case e.KEYS.ESC:a.show_autocomplete?(c(),a.$apply()):v.val(y);break;case e.KEYS.ENTER:a.show_autocomplete&&a.selected_index>0&&!a.waiting_for_suggestion&&(a.apply_selection(a.selected_index),t.stopPropagation(),t.preventDefault()),c(),a.$apply();break;case e.KEYS.TAB:if(!a.show_autocomplete)break;t.preventDefault();case e.KEYS.DOWN:a.results.length>0&&(a.show_autocomplete?(n=a.selected_index+1>a.results.length-1?0:a.selected_index+1,f(n),m(n)):(l(),f(0)),a.$apply());break;case e.KEYS.UP:a.show_autocomplete&&(t.preventDefault(),n=a.selected_index-1>=0?a.selected_index-1:a.results.length-1,f(n),m(n),a.$apply())}}},v.bind(e.EVENTS.KEYDOWN,b[e.EVENTS.KEYDOWN])}var h=this,b={};b[e.EVENTS.BLUR]=null,b[e.EVENTS.KEYDOWN]=null,b[e.EVENTS.RESIZE]=null;var v,S,N,y,w,$,A,T=a.options()||{},V={debounce_position:T.debounce_position||e.DEBOUNCE.position,debounce_attach:T.debounce_attach||e.DEBOUNCE.attach,debounce_suggest:T.debounce_suggest||e.DEBOUNCE.suggest,debounce_blur:T.debounce_blur||e.DEBOUNCE.blur};a.show_autocomplete=!1;var C=s(r,V.debounce_position),x=s(d,V.debounce_suggest);h.attach=s(E,V.debounce_attach),h.detach=function(){if(v){var t=v.val();p(t),N.on_detach&&N.on_detach(t),v.unbind(e.EVENTS.KEYDOWN,b[e.EVENTS.KEYDOWN]),v.unbind(e.EVENTS.BLUR,b[e.EVENTS.BLUR]),A&&v[0].removeAttribute("id")}c(),a.container[0].removeAttribute("aria-labelledby"),angular.element(n).unbind(e.EVENTS.RESIZE,b[e.EVENTS.RESIZE]),w&&w(),a.selected_index=a.results=void 0,S=v=y=void 0},a.apply_keyboard_selection=function(e){if(13==e){var t=f(1);$=t.value,p(t.value),c(),N.on_select&&N.on_select(t)}},a.apply_selection=function(e){if(v[0].focus(),!(!a.show_autocomplete||e>a.results.length||e<0)){var t=f(e);$=t.value,p(t.value),c(),N.on_select&&N.on_select(t)}},a.$on("$destroy",function(){h.detach(),a.container.remove()})}]}}]).directive("massAutocompleteItem",function(){return{restrict:"A",require:["^massAutocomplete","ngModel"],scope:{massAutocompleteItem:"&"},link:function(e,t,n,o){n.$set("autocomplete","off");var i=o[0],a=o[1];t.bind("focus",function(){var n=e.massAutocompleteItem();if(!n)throw new Error("Invalid options");i.attach(a,t,n)})}}})}(); \ No newline at end of file diff --git a/massautocomplete.theme.css b/massautocomplete.theme.css index bf24b8d..0efd3f5 100644 --- a/massautocomplete.theme.css +++ b/massautocomplete.theme.css @@ -1,3 +1,6 @@ +.mass-autocomplete { + position: relative; +} .ac-container { -webkit-backface-visibility: hidden; -moz-backface-visibility: hidden; diff --git a/package.json b/package.json index 4ad92e0..175d648 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "angular-mass-autocomplete", - "version": "0.4.0", + "version": "0.6.0", "description": "Autocomplete for Angular.js applications with a lot to complete", "main": "massautocomplete.js", "repository": {