Simple & intelligible numbered pagination in Angular
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:
- the least information to create closed pagination: start index, the total number of items and results per page.
- the least information to display on the pagination component: number of numbers to display on the page (the ruler).
- display first, last, previous and next buttons. And, of course, numbered pagination based on the ruler.
- a natural behaviour for the cursor (active page) when moving on the ruler.
- emit an event on page change only if the page actually changes.
- be compliant with SOLID principles.
- 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 paginationindex
is the active pagetotalCount
is the total number of itemspageSize
is the number of results per pagerulerLength
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 eventtrackByFn
will help the render engine to paint faster ~super vulgarised init?
With some little styling, we will get something like this:
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:
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.