Simple & intelligible numbered pagination in Angular

Michel Ruffieux
ITNEXT
Published in
8 min readSep 28, 2021

--

Photo by Studio Media on Unsplash

If you’re using Angular Material components in your application, you may have noticed their paginator has a quite small, yet extendable, API. However, sometimes it just makes sense to avoid importing a full Angular Material module and hack the API to meet your needs.

In this article, we will build a very simple numbered pagination with the least possible code using Angular and functional programming. We will start by defining our needs then break every step of the process to create simple inline pagination that you can use for both frontend and backend pagination.

The requirements

When you’re building a module or a component, you need to know what are the requirements. In terms of design, it can change everything. For this numbered pagination, we need:

  1. the least information to create closed pagination: start index, the total number of items and results per page.
  2. the least information to display on the pagination component: number of numbers to display on the page (the ruler).
  3. display first, last, previous and next buttons. And, of course, numbered pagination based on the ruler.
  4. a natural behaviour for the cursor (active page) when moving on the ruler.
  5. emit an event on page change only if the page actually changes.
  6. be compliant with SOLID principles.
  7. be as small as possible, yet intelligible.

Step one

First, we need to create an Angular Module to hold all the logic from the numbered pagination. We will keep it as small as possible:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
@NgModule({
imports: [CommonModule],
declarations: [],
exports: [],
})
export class NumberedPaginationModule {}

Depending on your stack, this module lands on either your shared/ folder or as a part of your UI library.

Once we have this module, we can create our component.

import { Component, ChangeDetectionStrategy, Input, Output } from '@angular/core';@Component({
selector: 'numbered-pagination',
templateUrl: './numbered-pagination.component.html',
styleUrls: ['./numbered-pagination.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NumberedPaginationComponent { maxPages: number; @Input() index: number;
@Input() totalCount: number;
@Input() pageSize: number;
@Input() rulerLength: number;
}

Basically, we have all the variables required to create our pagination component where:

maxPages represents the last page of our pagination
indexis the active page
totalCount is the total number of items
pageSize is the number of results per page
rulerLength is the number of page to display on the ruler

As you can see, maxPages is not an @Input() because it’s the responsibility of the NumberedPaginationComponent to define the maximum number of pages.

Have index as @Input() enables dynamic pagination in case our pagination is not just inline but is defined by a route, for example.

We will now register our component in our module.

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NumberedPaginationComponent } from './components';
@NgModule({
imports: [CommonModule],
declarations: [NumberedPaginationComponent]
exports: [NumberedPaginationComponent],
})
export class NumberedPaginationModule {}

The module is now fully ready 🎉

Step two

Now that our NumberedPaginationModule is ready to be imported. We need to work on our component. We’ve seen our component has a few @Input() but we will now set some default values in order to be compliant with TypeScript strict mode and because we want to offer some presets to our pagination.

We also want to emit an event on page change so we will add a simple @Output() that will inform the parent component about the page to load.

import { Component, ChangeDetectionStrategy, EventEmitter, Input, Output } from '@angular/core';@Component({
selector: 'numbered-pagination',
templateUrl: './numbered-pagination.component.html',
styleUrls: ['./numbered-pagination.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NumberedPaginationComponent { maxPages: number = 0; @Input() index: number = 1;
@Input() totalCount: number = 100;
@Input() pageSize: number = 5;
@Input() rulerLength: number = 5;
@Output() page: EventEmitter<number> = new EventEmitter<number>(); constructor() {
this.maxPages = Math.ceil(this.totalCount / this.pageSize);
}
}

For this example, I will set maxPages in the constructor.

Step three

So far, we have fulfilled the 2 first needs to build our numbered pagination. Let’s move on to the third one, the template.

I’m not gonna go into detail because it’s pretty intelligible and simple to read and understand what going on. I will just share the HTML and the interface result.

<ol class="pagination-container">
<li (click)="navigateToPage(1)">First page</li>
<li (click)="navigateToPage(index - 1)">Previous page</li>
<li
*ngFor="let page of pagination.pages; trackBy: trackByFn"
class="pagination-number"
[class.active]="page === pagination.index"
(click)="navigateToPage(page)">
{{ page }}
</li>
<li (click)="navigateToPage(index + 1)">Next page</li>
<li (click)="navigateToPage(maxPages)">Last page</li>
</ol>

navigateToPage(n) will handle the navigation and emit the event
trackByFn will help the render engine to paint faster ~super vulgarised init?

With some little styling, we will get something like this:

The ruler is the part that’s between the labels

Step four

It’s time to go back to our component. Based on the requirements we defined at the very beginning of this article, we’ve covered numbers 1 to 3. This step will go through the remaining needs.

On our template, we have an *ngFor that loops over a pages array. This array belongs to an object called pagination. First, we want to create an interface for this object.

export interface NumberedPagination {
index: number;
maxPages: number;
pages: number[];
}

It’s a very simple interface but it will hold everything we need for our pagination to work.

Let now create the pagination object in our component using a getter because we want this object to be reactive to changes.

import { Component, ChangeDetectionStrategy, Input, Output } from '@angular/core';
import { NumberedPagination } from './../../interfaces';
@Component({
selector: 'numbered-pagination',
templateUrl: './numbered-pagination.component.html',
styleUrls: ['./numbered-pagination.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NumberedPaginationComponent { maxPages: number = 0; @Input() index: number = 1;
@Input() totalCount: number = 100;
@Input() pageSize: number = 5;
@Input() rulerLength: number = 5;
@Output() page: EventEmitter<number> = new EventEmitter<number>(); constructor() {
this.maxPages = Math.ceil(this.totalCount / this.pageSize);
}
get pagination(): NumberedPagination {
const { index, maxPages, rulerLength } = this;
const pages = ruler(index, maxPages, rulerLength);
return { index, maxPages, pages } as NumberedPagination;
}
}

Our pagination object is composed with index that is the active page, maxPages that is the maximum number of pages on our pagination and pages that is the number of pages we want to display on the ruler.

The pages variable is composed by the output from the ruler() method. Let’s have a look at this function:

const ruler = (currentIndex: number, maxPages: number, rulerLength: number): number[] => {
const array = new Array(rulerLength).fill(null);
const min = Math.floor(rulerLength / 2);
return array.map((_, index) => rulerFactory(currentIndex, index, min, maxPages, rulerLength));
};

In this method, we create an empty array that is as big as the rulerLength defined by either the preset or the @Input(). In our case, the rulerLength is 5. We will use value from min to create a logical behavior for the ruler as there will 3 different ways to move the ruler but we will see below. In our case min will be 2.

Note: if we want to have a symmetrical ruler, we can to enforce the rulerLength to be an even number. We can easily achieve this altering its value:
rulerLength = rulerLength % 2 === 0 ? rulerLength + 1 : rulerLength

This ruler method is actually returning an array of numbers. For example, when creating the component with our presets, it will return [1, 2, 3, 4].

In the return of the method, we can see a new one called rulerFactory. The rulerFactory aims to avoid if-else statements. Instead, we will use the factory design pattern to abstract the pile of ifs from the ruler function.

Before we dissect the rulerFactory method, we need to introduce an enum. Since the ruler has 3 different behaviors, we create an enum to hold them:

export enum RulerFactoryOption {
Start = 'START',
End = 'END',
Default = 'DEFAULT',
}

Start will be used before the active page reaches the middle of the ruler.
End is the last behavior. When we will get closer to the last page.
Default is the default behavior.

Now let’s have a look at the rulerFactory:

const rulerFactory = (currentIndex: number, index: number, min: number, maxPages: number, rL: number): number => {
const factory = {
[RulerFactoryOption.Start]: () => index + 1
[RulerFactoryOption.End]: () => maxPages - rL + index + 1,
[RulerFactoryOption.Default]: () => currentIndex + index - min,
};
return factory[rulerOption(currentIndex, min, maxPages)]();};

Note: I had to rename rulerLength rL for better readability.

It may look a little obscure but what the factory does is actually pretty simple. The goal is to have a natural flow when moving on the ruler. The ruler factory defines, using rulerOption what behavior the ruler must return. In other words, the active page will always be in the center of the screen except at the beginning and at the end. Because of the last 2 cases, the ruler doesn’t change, only the page that’s highlighted:

Start, Default & End behavior

To define if we’re at the beginning, in the middle or at the end of the ruler, we use the rulerOption function:

const rulerOption = (currentIndex: number, min: number, maxPages: number): RulerFactoryOption => {
return currentIndex <= min
? RulerFactoryOption.Start
: currentIndex >= maxPages - min
? RulerFactoryOption.End
: RulerFactoryOption.Default;
};

Before we reach the end of this article, we need to update our component with two methods that were introduced on the template above:

navigateToPage(pageNumber: number): void {
if (allowNavigation(pageNumber, this.index, this.maxPages)) {
this.index = pageNumber;
this.page.emit(this.index);
}
}
trackByFn(index: number): number {
return index;
}

Our navigateToPage method, will trigger the index update and emit the page event only if the page is different than the current page and if the page exists.

The trackByFn helps Angular to track changes on *ngFor loops and makes painting faster. Strictly performance wise.

As per allowNavigation, we do not want to trigger a page event or update the index if didn’t change so we test as follow:

const allowNavigation = (pageNumber: number, index: number, maxPages: number): boolean => {
return pageNumber !== index && pageNumber > 0 && pageNumber <= maxPages;
};

Conclusion

The component is barely 70 lines of code so it’s a very tiny, zero dependency module that does the job pretty well.

If you prefer if-else statements to Factory, you can as well remove the rulerFactory method and run the conditional statements directly in the .map(). It’s even less code, you don’t need the enums as well. But IMHO, it’s always better to follow strict principles.

I hope you liked this article and wish you all a great time. It’s all love.

--

--