Pagination is undoubtedly one of the most popular approaches to dozed content representation. And it’s absolutely fine to use some out-of-the-box solutions for it if your application uses some library like NgBootstrap or Material UI all round and the designs and functionality they provide is satisfactory to you.
But once you bump into the need of re-styling or customisation of it you usually may choose the way of creating a wrapping component which uses this out-of-the-box component inside and re-define its behaviour or styles by breaking original component encapsulation.
This is a legit approach of course if there are few changes to make. But when you start modifying it over and over trying to satisfy some special customer needs there may be a point when you say: “Come on! It would be easier and faster to create it from scratch than to do all these customisations!”
And you will be absolutely right here! Let’s explore together the way how to implement it in your Angular app.
Impatient readers can find the whole project here. The rest are welcome to follow with me step by step.
I like to think about pagination as about select control: we have the list of options (pages) and select one of them. And this is the reason to implement it as a form control. And speaking in terms of Angular it means that we want to implement ControlValueAccessor
interface. There is an official documentation and a lot of other instructions and descriptions how to implement it. So, we’re not going to dwell on it and only will deal with pagination specific of it.
Of course you may say that we could make this component without implementing ControlValueAccessor interface just using @Output
property for handling page change events. This point of view is absolutely reasonable and furthermore we’ll have this version of our component here at first as well. But the benefit of implementing ControlValueAccessor is that it gives us more flexibility: we can use or pagination component either as a template form with one- or two-way data binding ([(ngModel)]
or [ngModel]
+ (ngModelChange)
) or as a reactive form control ([formControl]
).
The reactive approach can be very handy when our pagination is supposed to be used in combination with other form controls. For example, we may have a table component which offers user a possibility to sort and filter data by columns, use pagination and table lookup. In this case we can use RxJS combineLatest
method to subscribe and listen to the changes of all these controls only once:
combineLates([
this.searchControl.pipe(
startWith(this.searchControl.value),
debounceTime(500)
),
this.filterControl.pipe(startWith(this.filterControl.value)),
this.sortControl.pipe(startWith(this.sortControl.value)),
this.pageControl.pipe(
startWith(this.pageControl.value),
debounceTime(500)
)
]).subscribe((search, filter, sort, pagination) => {
this.sendRequest({
search,
filter,
sortBy: sort.sortBy,
sortDirection: sort.sortDirection,
page: pagination.page,
pageSize: pagination.pageSize
};
})
As we you can see above with this approach we have to pipe startWith
to each control, otherwise our subscription won’t start until each control emits at leas one value. In this example we use the initial form values to start with.
Another advantage of reactive approach is the possibility to use debounceTime
to particular controls which allows us to avoid triggering requests on each key stroke in search input or on each click on pagination Previous / Next button.
So, let’s finally start with pagination itself!
At the first step for the visualising purpose we create a simple array of 100 elements which we’ll paginate later:
// app.component.html
<div class="items-wrapper">
<my-card
*ngFor="let item of visibleItems.items"
[item]="item">
</my-card>
</div>
// app.component.ts
export interface PaginatedResponse<T> {
items: T[];
total: number;
}
...
public pageSize = 10;
private readonly items = Array.from(
Array(100).keys(),
(item) => item + 1
);
public visibleItems: PaginatedResponse<number> = {
items: this.items.slice(0, this.pageSize),
total: this.items.length,
};
As a rule the backend sends us a paginated response, which have at least 2 properties:
-
items
— array of items for the particular (requested) page -
total
— how many matching items they have in the whole.
We’re going to follow this structure in our example even though we don’t have any backend in this example. We’ll just be slicing needed range of the initial array imitating backend responses.
On the second step we create a simple pagination component which would allow us to navigate to the first, previous, next, last and particular page from the visible range with no interactions with parent component.
I’m saying visible range because we may have our content split into dozens or hundreds of pages, and we definitely don’t want to show all of them to the user. So we provide visibleRangeLength
property to limit the visible pages count.
// pagination.component.html
<div class="pagination">
<button
[disabled]="value === 1"
(click)="selectPage(1)">
<<
</button>
<button
[disabled]="value === 1"
(click)="selectPage(value - 1)">
<
</button>
<button
*ngFor="let page of visiblePages"
[ngClass]="value === page && 'selected'"
(click)="selectPage(page)">
{{ page }}
</button>
<button
[disabled]="value === totalPages
(click)="selectPage(value + 1)">
>
</button>
<button
[disabled]="value === totalPages"
(click)="selectPage(totalPages)>
>>
</button>
</div>
// pagination.component.ts
public value = 1;
public totalPages = 10;
public visibleRangeLength = 5;
public visiblePages: number[];
ngOnInit(): void {
this.updateVisiblePages();
}
public selectPage(page: number): void {
this.value = page;
this.updateVisiblePages();
}
private updateVisiblePages(): void {
const length = Math.min(this.totalPages, this.visibleRangeLength);
const startIndex = Math.max(
Math.min(
this.value - Math.ceil(length / 2),
this.totalPages - length
),
0
);
this.visiblePages = Array.from(
new Array(length).keys(),
(item) => item + startIndex + 1
);
}
The only tricky part here I guess is updateVisiblePages
method. The main points of it are the following:
- we always want to have visible as many pages as we set in
visibleRangeLength
property - if
visibleRangeLength
turns out to be bigger thantotalPages
count (and this is absolutely possible as we never know how many pages the backend has for us, especially if we request filtered results) we want to displaytotalPages
count - the selected page should be in the middle of the visible range if possible
Now this component is already functional but it can’t interact with external environment. To turn our component into useable configurable solution we need to:
- convert
value
,totalPages
,visibleRangeLength
into component inputs - to add component output
valueChange
and extendselectPage
method with the linethis.valueChange.emit(this.value)
Though we can refactor it a little bit. As we have said previously we usually don't get totalPages
value from the backend, but we do get total items quantity and then we calculate the totalPages
on our side depending on the page size. So, instead of having this logic in the AppComponent let’s put it in the PaginationComponent:
- convert
totalPages
back into the regular public property - provide input properties
total
(for the total items count) andpageSize
- add re-calculation of the
totalPages
value each time ourtotal
orpageSize
change
After finishing it our component looks like this:
// pagination.component.ts
@Input() value = 1;
@Input() total = 10;
@Input() pageSize = 10;
@Input() visibleRangeLength = 5;
@Output() valueChange = new EventEmitter<number>();
public totalPages: number;
public visiblePages: number[];
ngOnInit(): void {
this.updateVisiblePages();
}
ngOnChanges(changes: SimpleChanges): void {
if (changes.total || changes.pageSize) this.updateTotalPages();
}
public selectPage(page: number): void {
this.value = page;
this.updateVisiblePages();
this.valueChange.emit(this.value);
}
private updateVisiblePages(): void {
const length = Math.min(this.totalPages, this.visibleRangeLength);
const startIndex = Math.max(
Math.min(
this.value - Math.ceil(length / 2),
this.totalPages - length
),
0
);
this.visiblePages = Array.from(
new Array(length).keys(),
(item) => item + startIndex + 1
);
}
private updateTotalPages(): void {
this.totalPages = Math.ceil(this.total / this.pageSize);
}
Now we can apply the pagination to our list of items:
// app.component.html
...
<div class="pagination-wrapper">
<my-pagination
[total]="visibleItems.total"
[pageSize]="pageSize"
(valueChange)="onPageChange($event)">
</my-pagination>
</div>
// app.component.ts
...
public onPageChange(page: number): void {
const startIndex = (page - 1) * this.pageSize;
const items = this.items.slice(
startIndex,
startIndex + this.pageSize
);
this.visibleItems = { items, total: this.items.length };
}
On the third step I want to make our component responsible not only for the page but also for the page size selection. So we’ll add the select element to our pagination template and extend and refactor the component logic a bit more.
Obviously, our component should emit page size change event outside. But the business logic may be different here depending on the customer preferences. One may want the page content not to be updated on page size change and only request new data set on page number change. Another may have different thoughts about it.
I personally think that the most transparent way to handle it would be to request the new range of data on the page size change reseting the selected page to 1 in the same time. And this is the approach we’re going to implement further.
At first we’ll extend value
interface so that it includes two properties page
and pageSize
. This will also need to do some refactoring in the existing PaginationComponent code (like replacing value
with value.page
etc.).
Then we’ll add the following changes:
// pagination.component.html
...
<select
[ngModel]="value.pageSize"
(ngModelChange)="selectPageSize($event)">
<option
*ngFor="let size of pageSizes"
[value]="size">
{{ size }}
</option>
</select>
// pagination.component.ts
...
export interface PaginationValue {
page: number;
pageSize: number;
}
...
@Input() value: PaginationValue = { page: 1, pageSize: 5 };
...
@Input() pageSizes: number[] = [5, 10, 25, 50];
@Output() valueChange = new EventEmitter<PaginationValue>();
...
ngOnChanges(changes: SimpleChanges): void {
if (changes.total || changes.value) this.updateTotalPages();
}
public selectPage(page: number): void {
this.value = { ...this.value, page };
...
}
public selectPageSize(pageSize: string): void {
this.value = { page: 1, pageSize: +pageSize };
this.updateTotalPages();
this.updateVisiblePages();
this.valueChange.emit(this.value);
}
And finally let’s turn our component into the ControlValueAccessor
. For this we only need to remove everything related to the output property valueChange
and add the following:
@Component({
...
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => PaginationComponent),
multi: true,
}]
})
export class PaginationComponent implements OnInit, OnChanges, ControlValueAccessor {
...
onChange(value: any) {};
onTouched() {};
registerOnChange(fn: any): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
writeValue(value: PaginationValue): void {
if (!value) return;
this.value = value;
this.updateTotalPages();
this.updateVisiblePages();
}
public selectPage(page: number): void {
...
this.onChange(this.value);
}
public selectPageSize(pageSize: number): void {
...
this.onChange(this.value);
}
}
And now we can use the pagination as a form control:
// app.component.html
...
<my-pagination
[total]="visibleItems.total"
[formControl]="paginationControl">
</my-pagination>
<!-- OR -->
<my-pagination
[total]="visibleItems.total"
[ngModel]="pagination"
(ngModelChange)="onPageChange($event)">
</my-pagination>
// app.component.ts
...
public pagination = { page: 1, pageSize: 10 };
// OR
public readonly paginationControl = new FormControl(this.pagination);
ngOnInit(): void {
this.paginationControl.valueChanges
.subscribe(this.onPageChange.bind(this));
}
public onPageChange(pagination: PaginationValue): void {
const startIndex = (pagination.page - 1) * pagination.pageSize;
const items = this.items.slice(
startIndex,
startIndex + pagination.pageSize
);
this.visibleItems = { items, total: this.items.length };
}
Thank you for reading it and for your feedbacks!
Top comments (0)