diff --git a/src/auto-complete.js b/src/auto-complete.js index a5696ffc..8aa9de57 100644 --- a/src/auto-complete.js +++ b/src/auto-complete.js @@ -9,7 +9,8 @@ * Provides autocomplete support for the tagsInput directive. * * @param {expression} source Expression to evaluate upon changing the input content. The input value is available as - * $query. The result of the expression must be a promise that eventually resolves to an array of strings. + * $query and for pagination there is $skip and $limit. + * The result of the expression must be a promise that eventually resolves to an array of strings. * @param {string=} [template=NA] URL or id of a custom template for rendering each element of the autocomplete list. * @param {string=} [displayProperty=tagsInput.displayText] Property to be rendered as the autocomplete label. * @param {number=} [debounceDelay=100] Amount of time, in milliseconds, to wait before evaluating the expression in @@ -31,10 +32,12 @@ * The expression is provided with the current match as $match, its index as $index and its state as $selected. The result * of the evaluation must be one of the values supported by the ngClass directive (either a string, an array or an object). * See https://docs.angularjs.org/api/ng/directive/ngClass for more information. + * @param {boolean=} [loadMore=false] calls the source function with $query, $skip and $limit when the user reached the + * bottom of the suggestion list. */ -tagsInput.directive('autoComplete', function($document, $timeout, $sce, $q, tagsInputConfig, tiUtil) { - function SuggestionList(loadFn, options, events) { - var self = {}, getDifference, lastPromise, getTagId; +tagsInput.directive('autoComplete', function($document, $timeout, $sce, $q, tagsInputConfig, tiUtil, $window) { + function SuggestionList(loadFn, options, events, element) { + var self = {}, getDifference, lastPromise, getTagId, lastTags, resetLoadMore; getTagId = function() { return options.tagsInput.keyProperty || options.tagsInput.displayProperty; @@ -52,6 +55,35 @@ tagsInput.directive('autoComplete', function($document, $timeout, $sce, $q, tags }); }; + function setupLoadMore() { + var bottom = element[0].querySelector('.suggestion-bottom'); + var list = bottom.parentNode, queued = false; + function onScroll() { + var listRect = list.getBoundingClientRect(); + var bottomRect = bottom.getBoundingClientRect(); + var distance = bottomRect.bottom - listRect.bottom; + if (distance < 150) + { + self.load(self.query, lastTags, self.loadedPages + 1); + } + queued = false; + } + self.queueOnScroll = function() { + if (queued || self.eof || lastPromise !== undefined) { + return; + } + queued = true; + if ($window.requestAnimationFrame && arguments.length > 0) { + $window.requestAnimationFrame(onScroll); + } + else { + $timeout(onScroll, 16); + } + }; + + list.addEventListener('scroll',self.queueOnScroll); + resetLoadMore = function() { list.removeEventListener('scroll',self.queueOnScroll); }; + } self.reset = function() { lastPromise = null; @@ -60,30 +92,49 @@ tagsInput.directive('autoComplete', function($document, $timeout, $sce, $q, tags self.index = -1; self.selected = null; self.query = null; + self.loadedPages = 0; + self.eof = false; + + if (resetLoadMore) { + resetLoadMore(); + } }; self.show = function() { - if (options.selectFirstMatch) { + if (options.selectFirstMatch && self.loadedPages === 1) { self.select(0); } else { self.selected = null; } self.visible = true; + if (options.loadMore) { + $timeout(setupLoadMore); + } }; - self.load = tiUtil.debounce(function(query, tags) { + self.load = tiUtil.debounce(function(query, tags, page) { + page = page || 1; + lastTags = tags; self.query = query; - var promise = $q.when(loadFn({ $query: query })); + var promise = $q.when(loadFn({ $query: query, $skip: (page-1)*options.maxResultsToShow, $limit: options.maxResultsToShow })); lastPromise = promise; promise.then(function(items) { if (promise !== lastPromise) { return; } + lastPromise = undefined; + self.loadedPages = page; + self.eof = items.length < options.maxResultsToShow; items = tiUtil.makeObjectArray(items.data || items, getTagId()); items = getDifference(items, tags); - self.items = items.slice(0, options.maxResultsToShow); + if (page !== 1) { + self.items = self.items.concat(items); + } + else { + self.items = items.slice(0, options.maxResultsToShow); + } if (self.items.length > 0) { self.show(); @@ -154,10 +205,11 @@ tagsInput.directive('autoComplete', function($document, $timeout, $sce, $q, tags loadOnEmpty: [Boolean, false], loadOnFocus: [Boolean, false], selectFirstMatch: [Boolean, true], - displayProperty: [String, ''] + displayProperty: [String, ''], + loadMore: [Boolean, false] }); - $scope.suggestionList = new SuggestionList($scope.source, $scope.options, $scope.events); + $scope.suggestionList = new SuggestionList($scope.source, $scope.options, $scope.events, $element); this.registerAutocompleteMatch = function() { return { diff --git a/templates/auto-complete.html b/templates/auto-complete.html index 22ce2c3d..443a8a79 100644 --- a/templates/auto-complete.html +++ b/templates/auto-complete.html @@ -7,5 +7,6 @@ ng-mouseenter="suggestionList.select($index)"> +
  • \ No newline at end of file diff --git a/test/auto-complete.spec.js b/test/auto-complete.spec.js index febbb67e..c674e373 100644 --- a/test/auto-complete.spec.js +++ b/test/auto-complete.spec.js @@ -56,7 +56,7 @@ describe('autoComplete directive', function() { spyOn(parentCtrl, 'registerAutocomplete').and.returnValue(tagsInput); options = jQuery.makeArray(arguments).join(' '); - element = angular.element(''); + element = angular.element(''); parent.append(element); $compile(element)($scope); @@ -88,7 +88,7 @@ describe('autoComplete directive', function() { } function getSuggestions() { - return getSuggestionsBox().find('li'); + return getSuggestionsBox().find('.suggestion-item'); } function getSuggestion(index) { @@ -117,7 +117,6 @@ describe('autoComplete directive', function() { $timeout.flush(); resolve(items); } - describe('basic features', function() { it('ensures that the suggestions list is hidden by default', function() { expect(isSuggestionsBoxVisible()).toBe(false); @@ -631,7 +630,7 @@ describe('autoComplete directive', function() { $timeout.flush(); // Assert - expect($scope.loadItems).toHaveBeenCalledWith('ABC'); + expect($scope.loadItems).toHaveBeenCalledWith('ABC', 0, 10); }); it('doesn\'t call the load function when the down arrow key is pressed and the option is false', function() { @@ -680,7 +679,7 @@ describe('autoComplete directive', function() { $timeout.flush(); // Assert - expect($scope.loadItems).toHaveBeenCalledWith(''); + expect($scope.loadItems).toHaveBeenCalledWith('', 0, 10); }); it('doesn\'t call the load function when the input field becomes empty and the option is false', function(){ @@ -715,7 +714,7 @@ describe('autoComplete directive', function() { $timeout.flush(); // Assert - expect($scope.loadItems).toHaveBeenCalledWith('ABC'); + expect($scope.loadItems).toHaveBeenCalledWith('ABC', 0, 10); }); it('doesn\' call the load function when the input element gains focus and the option is false', function() { @@ -766,7 +765,7 @@ describe('autoComplete directive', function() { $timeout.flush(100); // Assert - expect($scope.loadItems).toHaveBeenCalledWith('ABC'); + expect($scope.loadItems).toHaveBeenCalledWith('ABC', 0, 10); }); it('doesn\'t call the load function when the reset method is called', function() { @@ -805,7 +804,7 @@ describe('autoComplete directive', function() { // Assert expect($scope.loadItems.calls.count()).toBe(1); - expect($scope.loadItems.calls.argsFor(0)).toEqual(['ABC']); + expect($scope.loadItems.calls.argsFor(0)).toEqual(['ABC', 0, 10]); }); it('doesn\'t call the load function when the minimum amount of characters isn\'t entered', function() { @@ -1372,4 +1371,44 @@ describe('autoComplete directive', function() { }); }); }); + describe('load-more', function() { + it('initializes the option to false', function() { + // Arrange + compile(); + + // Assert + expect(isolateScope.options.loadMore).toBe(false); + }); + it('calls the load function with skip and limit params when user approaches the bottom',function() { + // Arrange + compile('load-more="true"'); + loadSuggestions(12); + $scope.$digest(); + $timeout.flush(); + $scope.loadItems = jasmine.createSpy().and.returnValue($q.when([])); + + // Act + suggestionList.queueOnScroll(); + $timeout.flush(); + $timeout.flush(); + + // Assert + expect($scope.loadItems).toHaveBeenCalledWith('foobar',10,10); + }); + it('avoids calling the load function when there are no more results',function() { + // Arrange + compile('load-more="true"'); + loadSuggestions(9); + $scope.$digest(); + $timeout.flush(); + $scope.loadItems = jasmine.createSpy().and.returnValue($q.when([])); + + // Act + suggestionList.queueOnScroll(); + $timeout.verifyNoPendingTasks(); + + // Assert + expect($scope.loadItems).not.toHaveBeenCalled(); + }); + }); });