2 * dirPagination - AngularJS module for paginating (almost) anything.
8 * Daniel Tabuenca: https://groups.google.com/d/msg/angular/an9QpzqIYiM/r8v-3W1X5vcJ
9 * for the idea on how to dynamically invoke the ng-repeat directive.
11 * I borrowed a couple of lines and a few attribute names from the AngularUI Bootstrap project:
12 * https://github.com/angular-ui/bootstrap/blob/master/src/pagination/pagination.js
14 * Copyright 2014 Michael Bromley <michael@michaelbromley.co.uk>
22 var moduleName = 'angularUtils.directives.dirPagination';
23 var DEFAULT_ID = '__default';
28 angular.module(moduleName, [])
29 .directive('dirPaginate', ['$compile', '$parse', 'paginationService', dirPaginateDirective])
30 .directive('dirPaginateNoCompile', noCompileDirective)
31 .directive('dirPaginationControls', ['paginationService', 'paginationTemplate', dirPaginationControlsDirective])
32 .filter('itemsPerPage', ['paginationService', itemsPerPageFilter])
33 .service('paginationService', paginationService)
34 .provider('paginationTemplate', paginationTemplateProvider)
35 .run(['$templateCache',dirPaginationControlsTemplateInstaller]);
37 function dirPaginateDirective($compile, $parse, paginationService) {
43 compile: dirPaginationCompileFn
46 function dirPaginationCompileFn(tElement, tAttrs){
48 var expression = tAttrs.dirPaginate;
49 // regex taken directly from https://github.com/angular/angular.js/blob/v1.4.x/src/ng/directive/ngRepeat.js#L339
50 var match = expression.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+as\s+([\s\S]+?))?(?:\s+track\s+by\s+([\s\S]+?))?\s*$/);
52 var filterPattern = /\|\s*itemsPerPage\s*:\s*(.*\(\s*\w*\)|([^\)]*?(?=\s+as\s+))|[^\)]*)/;
53 if (match[2].match(filterPattern) === null) {
54 throw 'pagination directive: the \'itemsPerPage\' filter must be set.';
56 var itemsPerPageFilterRemoved = match[2].replace(filterPattern, '');
57 var collectionGetter = $parse(itemsPerPageFilterRemoved);
59 addNoCompileAttributes(tElement);
61 // If any value is specified for paginationId, we register the un-evaluated expression at this stage for the benefit of any
62 // dir-pagination-controls directives that may be looking for this ID.
63 var rawId = tAttrs.paginationId || DEFAULT_ID;
64 paginationService.registerInstance(rawId);
66 return function dirPaginationLinkFn(scope, element, attrs){
68 // Now that we have access to the `scope` we can interpolate any expression given in the paginationId attribute and
69 // potentially register a new ID if it evaluates to a different value than the rawId.
70 var paginationId = $parse(attrs.paginationId)(scope) || attrs.paginationId || DEFAULT_ID;
72 // (TODO: this seems sound, but I'm reverting as many bug reports followed it's introduction in 0.11.0.
73 // Needs more investigation.)
74 // In case rawId != paginationId we deregister using rawId for the sake of general cleanliness
75 // before registering using paginationId
76 // paginationService.deregisterInstance(rawId);
77 paginationService.registerInstance(paginationId);
79 var repeatExpression = getRepeatExpression(expression, paginationId);
80 addNgRepeatToElement(element, attrs, repeatExpression);
82 removeTemporaryAttributes(element);
83 var compiled = $compile(element);
85 var currentPageGetter = makeCurrentPageGetterFn(scope, attrs, paginationId);
86 paginationService.setCurrentPageParser(paginationId, currentPageGetter, scope);
88 if (typeof attrs.totalItems !== 'undefined') {
89 paginationService.setAsyncModeTrue(paginationId);
90 scope.$watch(function() {
91 return $parse(attrs.totalItems)(scope);
92 }, function (result) {
94 paginationService.setCollectionLength(paginationId, result);
98 paginationService.setAsyncModeFalse(paginationId);
99 scope.$watchCollection(function() {
100 return collectionGetter(scope);
101 }, function(collection) {
103 var collectionLength = (collection instanceof Array) ? collection.length : Object.keys(collection).length;
104 paginationService.setCollectionLength(paginationId, collectionLength);
109 // Delegate to the link function returned by the new compilation of the ng-repeat
112 // (TODO: Reverting this due to many bug reports in v 0.11.0. Needs investigation as the
113 // principle is sound)
114 // When the scope is destroyed, we make sure to remove the reference to it in paginationService
115 // so that it can be properly garbage collected
116 // scope.$on('$destroy', function destroyDirPagination() {
117 // paginationService.deregisterInstance(paginationId);
123 * If a pagination id has been specified, we need to check that it is present as the second argument passed to
124 * the itemsPerPage filter. If it is not there, we add it and return the modified expression.
127 * @param paginationId
130 function getRepeatExpression(expression, paginationId) {
131 var repeatExpression,
132 idDefinedInFilter = !!expression.match(/(\|\s*itemsPerPage\s*:[^|]*:[^|]*)/);
134 if (paginationId !== DEFAULT_ID && !idDefinedInFilter) {
135 repeatExpression = expression.replace(/(\|\s*itemsPerPage\s*:\s*[^|\s]*)/, "$1 : '" + paginationId + "'");
137 repeatExpression = expression;
140 return repeatExpression;
144 * Adds the ng-repeat directive to the element. In the case of multi-element (-start, -end) it adds the
145 * appropriate multi-element ng-repeat to the first and last element in the range.
148 * @param repeatExpression
150 function addNgRepeatToElement(element, attrs, repeatExpression) {
151 if (element[0].hasAttribute('dir-paginate-start') || element[0].hasAttribute('data-dir-paginate-start')) {
152 // using multiElement mode (dir-paginate-start, dir-paginate-end)
153 attrs.$set('ngRepeatStart', repeatExpression);
154 element.eq(element.length - 1).attr('ng-repeat-end', true);
156 attrs.$set('ngRepeat', repeatExpression);
161 * Adds the dir-paginate-no-compile directive to each element in the tElement range.
164 function addNoCompileAttributes(tElement) {
165 angular.forEach(tElement, function(el) {
166 if (el.nodeType === 1) {
167 angular.element(el).attr('dir-paginate-no-compile', true);
173 * Removes the variations on dir-paginate (data-, -start, -end) and the dir-paginate-no-compile directives.
176 function removeTemporaryAttributes(element) {
177 angular.forEach(element, function(el) {
178 if (el.nodeType === 1) {
179 angular.element(el).removeAttr('dir-paginate-no-compile');
182 element.eq(0).removeAttr('dir-paginate-start').removeAttr('dir-paginate').removeAttr('data-dir-paginate-start').removeAttr('data-dir-paginate');
183 element.eq(element.length - 1).removeAttr('dir-paginate-end').removeAttr('data-dir-paginate-end');
187 * Creates a getter function for the current-page attribute, using the expression provided or a default value if
188 * no current-page expression was specified.
192 * @param paginationId
195 function makeCurrentPageGetterFn(scope, attrs, paginationId) {
196 var currentPageGetter;
197 if (attrs.currentPage) {
198 currentPageGetter = $parse(attrs.currentPage);
200 // If the current-page attribute was not set, we'll make our own.
201 // Replace any non-alphanumeric characters which might confuse
202 // the $parse service and give unexpected results.
203 // See https://github.com/michaelbromley/angularUtils/issues/233
204 var defaultCurrentPage = (paginationId + '__currentPage').replace(/\W/g, '_');
205 scope[defaultCurrentPage] = 1;
206 currentPageGetter = $parse(defaultCurrentPage);
208 return currentPageGetter;
213 * This is a helper directive that allows correct compilation when in multi-element mode (ie dir-paginate-start, dir-paginate-end).
214 * It is dynamically added to all elements in the dir-paginate compile function, and it prevents further compilation of
215 * any inner directives. It is then removed in the link function, and all inner directives are then manually compiled.
217 function noCompileDirective() {
224 function dirPaginationControlsTemplateInstaller($templateCache) {
225 $templateCache.put('angularUtils.directives.dirPagination.template', '<ul class="pagination" ng-if="1 < pages.length || !autoHide"><li ng-if="boundaryLinks" ng-class="{ disabled : pagination.current == 1 }"><a href="" ng-click="setCurrent(1)">«</a></li><li ng-if="directionLinks" ng-class="{ disabled : pagination.current == 1 }"><a href="" ng-click="setCurrent(pagination.current - 1)">‹</a></li><li ng-repeat="pageNumber in pages track by tracker(pageNumber, $index)" ng-class="{ active : pagination.current == pageNumber, disabled : pageNumber == \'...\' || ( ! autoHide && pages.length === 1 ) }"><a href="" ng-click="setCurrent(pageNumber)">{{ pageNumber }}</a></li><li ng-if="directionLinks" ng-class="{ disabled : pagination.current == pagination.last }"><a href="" ng-click="setCurrent(pagination.current + 1)">›</a></li><li ng-if="boundaryLinks" ng-class="{ disabled : pagination.current == pagination.last }"><a href="" ng-click="setCurrent(pagination.last)">»</a></li></ul>');
228 function dirPaginationControlsDirective(paginationService, paginationTemplate) {
230 var numberRegex = /^\d+$/;
240 link: dirPaginationControlsLinkFn
243 // We need to check the paginationTemplate service to see whether a template path or
244 // string has been specified, and add the `template` or `templateUrl` property to
245 // the DDO as appropriate. The order of priority to decide which template to use is
246 // (highest priority first):
247 // 1. paginationTemplate.getString()
248 // 2. attrs.templateUrl
249 // 3. paginationTemplate.getPath()
250 var templateString = paginationTemplate.getString();
251 if (templateString !== undefined) {
252 DDO.template = templateString;
254 DDO.templateUrl = function(elem, attrs) {
255 return attrs.templateUrl || paginationTemplate.getPath();
260 function dirPaginationControlsLinkFn(scope, element, attrs) {
262 // rawId is the un-interpolated value of the pagination-id attribute. This is only important when the corresponding dir-paginate directive has
263 // not yet been linked (e.g. if it is inside an ng-if block), and in that case it prevents this controls directive from assuming that there is
264 // no corresponding dir-paginate directive and wrongly throwing an exception.
265 var rawId = attrs.paginationId || DEFAULT_ID;
266 var paginationId = scope.paginationId || attrs.paginationId || DEFAULT_ID;
268 if (!paginationService.isRegistered(paginationId) && !paginationService.isRegistered(rawId)) {
269 var idMessage = (paginationId !== DEFAULT_ID) ? ' (id: ' + paginationId + ') ' : ' ';
270 if (window.console) {
271 console.warn('Pagination directive: the pagination controls' + idMessage + 'cannot be used without the corresponding pagination directive, which was not found at link time.');
275 if (!scope.maxSize) { scope.maxSize = 9; }
276 scope.autoHide = scope.autoHide === undefined ? true : scope.autoHide;
277 scope.directionLinks = angular.isDefined(attrs.directionLinks) ? scope.$parent.$eval(attrs.directionLinks) : true;
278 scope.boundaryLinks = angular.isDefined(attrs.boundaryLinks) ? scope.$parent.$eval(attrs.boundaryLinks) : false;
280 var paginationRange = Math.max(scope.maxSize, 5);
292 scope.$watch('maxSize', function(val) {
294 paginationRange = Math.max(scope.maxSize, 5);
295 generatePagination();
299 scope.$watch(function() {
300 if (paginationService.isRegistered(paginationId)) {
301 return (paginationService.getCollectionLength(paginationId) + 1) * paginationService.getItemsPerPage(paginationId);
303 }, function(length) {
305 generatePagination();
309 scope.$watch(function() {
310 if (paginationService.isRegistered(paginationId)) {
311 return (paginationService.getItemsPerPage(paginationId));
313 }, function(current, previous) {
314 if (current != previous && typeof previous !== 'undefined') {
315 goToPage(scope.pagination.current);
319 scope.$watch(function() {
320 if (paginationService.isRegistered(paginationId)) {
321 return paginationService.getCurrentPage(paginationId);
323 }, function(currentPage, previousPage) {
324 if (currentPage != previousPage) {
325 goToPage(currentPage);
329 scope.setCurrent = function(num) {
330 if (paginationService.isRegistered(paginationId) && isValidPageNumber(num)) {
331 num = parseInt(num, 10);
332 paginationService.setCurrentPage(paginationId, num);
337 * Custom "track by" function which allows for duplicate "..." entries on long lists,
338 * yet fixes the problem of wrongly-highlighted links which happens when using
339 * "track by $index" - see https://github.com/michaelbromley/angularUtils/issues/153
344 scope.tracker = function(id, index) {
345 return id + '_' + index;
348 function goToPage(num) {
349 if (paginationService.isRegistered(paginationId) && isValidPageNumber(num)) {
350 var oldPageNumber = scope.pagination.current;
352 scope.pages = generatePagesArray(num, paginationService.getCollectionLength(paginationId), paginationService.getItemsPerPage(paginationId), paginationRange);
353 scope.pagination.current = num;
356 // if a callback has been set, then call it with the page number as the first argument
357 // and the previous page number as a second argument
358 if (scope.onPageChange) {
361 oldPageNumber : oldPageNumber
367 function generatePagination() {
368 if (paginationService.isRegistered(paginationId)) {
369 var page = parseInt(paginationService.getCurrentPage(paginationId)) || 1;
370 scope.pages = generatePagesArray(page, paginationService.getCollectionLength(paginationId), paginationService.getItemsPerPage(paginationId), paginationRange);
371 scope.pagination.current = page;
372 scope.pagination.last = scope.pages[scope.pages.length - 1];
373 if (scope.pagination.last < scope.pagination.current) {
374 scope.setCurrent(scope.pagination.last);
382 * This function updates the values (lower, upper, total) of the `scope.range` object, which can be used in the pagination
383 * template to display the current page range, e.g. "showing 21 - 40 of 144 results";
385 function updateRangeValues() {
386 if (paginationService.isRegistered(paginationId)) {
387 var currentPage = paginationService.getCurrentPage(paginationId),
388 itemsPerPage = paginationService.getItemsPerPage(paginationId),
389 totalItems = paginationService.getCollectionLength(paginationId);
391 scope.range.lower = (currentPage - 1) * itemsPerPage + 1;
392 scope.range.upper = Math.min(currentPage * itemsPerPage, totalItems);
393 scope.range.total = totalItems;
396 function isValidPageNumber(num) {
397 return (numberRegex.test(num) && (0 < num && num <= scope.pagination.last));
402 * Generate an array of page numbers (or the '...' string) which is used in an ng-repeat to generate the
403 * links used in pagination
407 * @param paginationRange
408 * @param collectionLength
411 function generatePagesArray(currentPage, collectionLength, rowsPerPage, paginationRange) {
413 var totalPages = Math.ceil(collectionLength / rowsPerPage);
414 var halfWay = Math.ceil(paginationRange / 2);
417 if (currentPage <= halfWay) {
419 } else if (totalPages - halfWay < currentPage) {
425 var ellipsesNeeded = paginationRange < totalPages;
427 while (i <= totalPages && i <= paginationRange) {
428 var pageNumber = calculatePageNumber(i, currentPage, paginationRange, totalPages);
430 var openingEllipsesNeeded = (i === 2 && (position === 'middle' || position === 'end'));
431 var closingEllipsesNeeded = (i === paginationRange - 1 && (position === 'middle' || position === 'start'));
432 if (ellipsesNeeded && (openingEllipsesNeeded || closingEllipsesNeeded)) {
435 pages.push(pageNumber);
443 * Given the position in the sequence of pagination links [i], figure out what page number corresponds to that position.
447 * @param paginationRange
451 function calculatePageNumber(i, currentPage, paginationRange, totalPages) {
452 var halfWay = Math.ceil(paginationRange/2);
453 if (i === paginationRange) {
455 } else if (i === 1) {
457 } else if (paginationRange < totalPages) {
458 if (totalPages - halfWay < currentPage) {
459 return totalPages - paginationRange + i;
460 } else if (halfWay < currentPage) {
461 return currentPage - halfWay + i;
472 * This filter slices the collection into pages based on the current page number and number of items per page.
473 * @param paginationService
474 * @returns {Function}
476 function itemsPerPageFilter(paginationService) {
478 return function(collection, itemsPerPage, paginationId) {
479 if (typeof (paginationId) === 'undefined') {
480 paginationId = DEFAULT_ID;
482 if (!paginationService.isRegistered(paginationId)) {
483 throw 'pagination directive: the itemsPerPage id argument (id: ' + paginationId + ') does not match a registered pagination-id.';
487 if (angular.isObject(collection)) {
488 itemsPerPage = parseInt(itemsPerPage) || 9999999999;
489 if (paginationService.isAsyncMode(paginationId)) {
492 start = (paginationService.getCurrentPage(paginationId) - 1) * itemsPerPage;
494 end = start + itemsPerPage;
495 paginationService.setItemsPerPage(paginationId, itemsPerPage);
497 if (collection instanceof Array) {
498 // the array just needs to be sliced
499 return collection.slice(start, end);
501 // in the case of an object, we need to get an array of keys, slice that, then map back to
502 // the original object.
503 var slicedObject = {};
504 angular.forEach(keys(collection).slice(start, end), function(key) {
505 slicedObject[key] = collection[key];
516 * Shim for the Object.keys() method which does not exist in IE < 9
524 if (obj.hasOwnProperty(i)) {
530 return Object.keys(obj);
535 * This service allows the various parts of the module to communicate and stay in sync.
537 function paginationService() {
540 var lastRegisteredInstance;
542 this.registerInstance = function(instanceId) {
543 if (typeof instances[instanceId] === 'undefined') {
544 instances[instanceId] = {
547 lastRegisteredInstance = instanceId;
551 this.deregisterInstance = function(instanceId) {
552 delete instances[instanceId];
555 this.isRegistered = function(instanceId) {
556 return (typeof instances[instanceId] !== 'undefined');
559 this.getLastInstanceId = function() {
560 return lastRegisteredInstance;
563 this.setCurrentPageParser = function(instanceId, val, scope) {
564 instances[instanceId].currentPageParser = val;
565 instances[instanceId].context = scope;
567 this.setCurrentPage = function(instanceId, val) {
568 instances[instanceId].currentPageParser.assign(instances[instanceId].context, val);
570 this.getCurrentPage = function(instanceId) {
571 var parser = instances[instanceId].currentPageParser;
572 return parser ? parser(instances[instanceId].context) : 1;
575 this.setItemsPerPage = function(instanceId, val) {
576 instances[instanceId].itemsPerPage = val;
578 this.getItemsPerPage = function(instanceId) {
579 return instances[instanceId].itemsPerPage;
582 this.setCollectionLength = function(instanceId, val) {
583 instances[instanceId].collectionLength = val;
585 this.getCollectionLength = function(instanceId) {
586 return instances[instanceId].collectionLength;
589 this.setAsyncModeTrue = function(instanceId) {
590 instances[instanceId].asyncMode = true;
593 this.setAsyncModeFalse = function(instanceId) {
594 instances[instanceId].asyncMode = false;
597 this.isAsyncMode = function(instanceId) {
598 return instances[instanceId].asyncMode;
603 * This provider allows global configuration of the template path used by the dir-pagination-controls directive.
605 function paginationTemplateProvider() {
607 var templatePath = 'angularUtils.directives.dirPagination.template';
611 * Set a templateUrl to be used by all instances of <dir-pagination-controls>
612 * @param {String} path
614 this.setPath = function(path) {
619 * Set a string of HTML to be used as a template by all instances
620 * of <dir-pagination-controls>. If both a path *and* a string have been set,
621 * the string takes precedence.
622 * @param {String} str
624 this.setString = function(str) {
625 templateString = str;
628 this.$get = function() {
630 getPath: function() {
633 getString: function() {
634 return templateString;