Angular: Пользовательская стилизация MatPaginator
Как фронтенд-разработчик вы, скорее всего, сталкивались с задачей отображения данных в таблице, и как фронтенд-разработчик Angular, вы, вероятно, выбрали для этого Angular Material.
Можно создать бесконечную прокрутку таблицы, начиная с нескольких начальных элементов, однако, несмотря на то, что это хорошее UX-решение, иногда нам приходится использовать традиционную пагинацию.
Набрав в поиске MatPaginator, вы найдете свое решение, однако следует отметить одну вещь... Пагинация в Angular выглядит совсем не привлекательно. Было бы неплохо сохранить навигацию, но изменить ее внешний вид на что-то другое?
Цель
В данной статье мы пошагово рассмотрим, как можно реализовать пользовательскую директиву и прикрепить ее к mat-paginator
, что полностью изменит его пользовательский интерфейс в соответствии с приведенным ниже примером.
Весь исходный код доступен на StackBlitz.
Мы создадим директиву appBubblePagination
, которая будет использовать возможности Renderer2
для создания пользовательских элементов пользовательского интерфейса, заменяющих стандартный макет пагинации, а использование директивы будет выглядеть следующим образом:
<table mat-table [dataSource]="dataSource" [trackBy]="identity">
<!-- table content -->
</table>
<mat-paginator
appBubblePagination
[appCustomLength]="dataSource.data.length"
[length]="dataSource.data.length"
[pageSize]="20"
>
</mat-paginator>
Шаг 1. Создать директиву
Для достижения цели - изменения визуальной составляющей mat-пагинации - мы создаем директиву и импортируем следующие зависимости.
@Directive({
selector: '[appBubblePagination]',
standalone: true,
})
export class BubblePaginationDirective {
constructor(
@Host() @Self() @Optional() private readonly matPag: MatPaginator,
private elementRef: ElementRef,
private ren: Renderer2
) {}
}
Зависимости используются следующим образом:
MatPaginator
- ссылка на подключаемыйmat-paginator
. Он будет использоваться для ручного изменения индекса пагинации путем нажатия на пользовательские пузырьки.ElementRef
- ссылка на DOM-элементы (mat-paginator
), к которым привязана директива. Она будет использоваться для получения ссылки на то, где рендерить дополнительные HTML-элементы. Подробнее об этом можно прочитать в документации Angular.Renderer2
- позволяет рендерить HTML-элементы, добавлять/удалять css-классы и подключать слушателей (click, hover). Подробнее об этом можно прочитать в документации Angular.
Шаг 2. Изменение макета по умолчанию
Далее мы хотим немного изменить визуальную составляющую стандартной пагинации. Ниже приведена иллюстрация начального и конечного состояния.
Изменения включают в себя:
- Удаление текста "элементы на странице"
- Помещение текущего номера пагинации (1-20) вправо и изменение его визуального оформления
С помощью зависимостей ElementRef
и Renderer2
мы модифицируем макет следующим образом:
export class BubblePaginationDirective implements AfterViewInit {
ngAfterViewInit(): void {
this.styleDefaultPagination();
}
private styleDefaultPagination() {
const nativeElement = this.elementRef.nativeElement;
const itemsPerPage = nativeElement.querySelector(
'.mat-mdc-paginator-page-size'
);
const howManyDisplayedEl = nativeElement.querySelector(
'.mat-mdc-paginator-range-label'
);
// remove 'items per page'
this.ren.setStyle(itemsPerPage, 'display', 'none');
// style text of how many elements are currently displayed
this.ren.setStyle(howManyDisplayedEl, 'position', 'absolute');
this.ren.setStyle(howManyDisplayedEl, 'left', '0');
this.ren.setStyle(howManyDisplayedEl, 'color', '#919191');
this.ren.setStyle(howManyDisplayedEl, 'font-size', '14px');
}
}
Шаг 3. Вставка DIV между кнопкой навигации
При осмотре HTML-элемента мы видим, что к элементу-обертке div прикреплен класс mat-mdc-paginator-range-actions
. Мы хотим выделить этот div и вставить новый div
-элемент между кнопками со стрелками влево и вправо. Он будет использоваться в качестве места, где будут генерироваться пользовательские кнопки пагинации с пузырьками.
ngAfterViewInit(): void {
this.styleDefaultPagination();
this.createBubbleDivRef();
}
private createBubbleDivRef(): void {
const actionContainer = this.elementRef.nativeElement.querySelector(
'div.mat-mdc-paginator-range-actions'
);
const nextButtonDefault = this.elementRef.nativeElement.querySelector(
'button.mat-mdc-paginator-navigation-next'
);
// create a HTML element where all bubbles will be rendered
this.bubbleContainerRef = this.ren.createElement('div') as HTMLElement;
this.ren.addClass(this.bubbleContainerRef, 'g-bubble-container');
// render element before the 'next button' is displayed
this.ren.insertBefore(
actionContainer,
this.bubbleContainerRef,
nextButtonDefault
);
}
Мы получаем ссылку на элемент-обертку div через actionContainer
, однако нам также необходимо значение nextButtonDefault
, чтобы правильно вложить наш элемент div
в DOM, где будут отображаться кнопки. Мы также сохраняем эту ссылку в bubbleContainerRef
.
С помощью функции this.ren.insertBefore()
мы присоединяем div
-элемент внутрь класса mat-mdc-paginator-range-actions
, помещая div
перед следующей кнопкой пагинации.
Если бы мы использовали this.ren.appendChild(actionContainer, this.bubbleContainerRef)
, то в итоге, после стрелочной навигации, были бы отображены кнопки.
Шаг 4. Рендеринг кнопок в DOM
На четвертом шаге мы хотим отобразить первую и последнюю кнопки и добавить точки между ними и остальными кнопками. Даже если в данном примере мы отобразим всё, то увидим, что изначально для каждого элемента установлено значение display: none
, а на следующем шаге мы раскроем только необходимые элементы.
export class BubblePaginationDirective implements AfterViewInit {
/**
* how many elements are in the table
*/
@Input() appCustomLength: number = 0;
ngAfterViewInit(): void {
this.styleDefaultPagination();
this.createBubbleDivRef();
this.buildButtons();
}
// .... previous code
/**
* end result: (1) .... (4) (5) (6) ... (25)
*/
private buildButtons(): void {
const neededButtons = Math.ceil(
this.appCustomLength / this.matPag.pageSize
);
// if there is only one page, do not render buttons
if (neededButtons === 1) {
this.ren.setStyle(this.elementRef.nativeElement, 'display', 'none');
return;
}
// create first button
this.buttonsRef = [this.createButton(0)];
// add dots (....) to UI
this.dotsStartRef = this.createDotsElement();
// create all buttons needed for navigation (except the first & last one)
for (let index = 1; index < neededButtons - 1; index++) {
this.buttonsRef = [...this.buttonsRef, this.createButton(index)];
}
// add dots (....) to UI
this.dotsEndRef = this.createDotsElement();
// create last button to UI after the dots (....)
this.buttonsRef = [
...this.buttonsRef,
this.createButton(neededButtons - 1),
];
}
/**
* create button HTML element
*/
private createButton(i: number): HTMLElement {
const bubbleButton = this.ren.createElement('div');
const text = this.ren.createText(String(i + 1));
// add class & text
this.ren.addClass(bubbleButton, 'g-bubble');
this.ren.setStyle(bubbleButton, 'margin-right', '8px');
this.ren.appendChild(bubbleButton, text);
// react on click
this.ren.listen(bubbleButton, 'click', () => {
this.switchPage(i);
});
// render on UI
this.ren.appendChild(this.bubbleContainerRef, bubbleButton);
// set style to hidden by default
this.ren.setStyle(bubbleButton, 'display', 'none');
return bubbleButton;
}
/**
* Helper function to switch page
*/
private switchPage(i: number): void {
const previousPageIndex = this.matPag.pageIndex;
this.matPag.pageIndex = i;
this.matPag['_emitPageEvent'](previousPageIndex);
}
Давайте подведем итоги.
Функция buildButtons()
:
- Функция вычисляет, сколько кнопок пагинации необходимо отобразить по параметру
neededButtons
, который представляет собой общее количество элементов в таблице (appCustomLength
), деленное на размер пагинации. - Если элементов в таблице меньше, чем размер пагинации, то мы ничего не выводим
- Если в таблице больше элементов (
neededButtons
> 1), то мы:
- отрисовываем первую кнопку
- отрисовываем точки
- отрисовываем остальные кнопки, кроме последней
- отрисовываем точки
- отрисовываем последнюю кнопку
- сохраняем кнопки из
createButton()
в массивbuttonsRef
, так как они понадобятся в дальнейшем
Функция createButton()
:
- Функция получает индекс, увеличивает его на единицу и прикрепляет это значение к кнопке в виде текста с некоторыми дополнительными классами CSS
- Каждая кнопка скрыта в DOM, что задается параметром
this.ren.setStyle(bubbleButton, 'display', 'none');
. На последующих шагах мы будем отображать/скрывать некоторые из них по мере того, как пользователь будет осуществлять навигацию, щелкая на них. - Прикрепив к каждой кнопке слушатель события click посредством
this.ren.listen(bubbleButton, 'click' ...)
, мы вызываем вспомогательную функциюswitchPage()
для смены текущей страницы.
В приведенном примере функция createDotsElement()
отсутствует, так как она является функцией рендеринга, аналогичной createButton()
, и мы также создаем некоторые CSS-классы, например g-bubble
, для стилизации.
Примечание: В switchPage()
я не совсем понимаю, почему при использовании _emitPageEvent
нужно передавать предыдущий индекс страницы (previousPageIndex)
. Я не смог найти ответа на этот вопрос, но это работает.
Шаг 5. Прослушивание навигационных кликов пользователя
В качестве последнего шага мы хотим реализовать логику, которая будет воспринимать щелчки пользователя на кнопках-пузырьках и изменять активный индекс с помощью стилизации.
В данный момент все кнопки скрыты, а их ссылки хранятся в buttonsRef
. Мы хотим отобразить только первые 2-3 кнопки, а по мере навигации пользователя в любом направлении отображать дополнительные 2 кнопки справа и слева, а также точки между первой и последней кнопками, если пользователь находится в середине навигации.
export class BubblePaginationDirective implements AfterViewInit {
private buttonsRef: HTMLElement[] = [];
// .... previous code
ngAfterViewInit(): void {
this.styleDefaultPagination();
this.createBubbleDivRef();
this.buildButtons();
this.matPag.page
.pipe(
map((e) => [e.previousPageIndex ?? 0, e.pageIndex]),
startWith([0, 0])
)
.subscribe(([prev, curr]) => {
this.changeActiveButtonStyles(prev, curr);
});
}
// .... previous code
private changeActiveButtonStyles(previousIndex: number, newIndex: number) {
const previouslyActive = this.buttonsRef[previousIndex];
const currentActive = this.buttonsRef[newIndex];
// remove active style from previously active button
this.ren.removeClass(previouslyActive, 'g-bubble__active');
// add active style to new active button
this.ren.addClass(currentActive, 'g-bubble__active');
// hide all buttons
this.buttonsRef.forEach((button) =>
this.ren.setStyle(button, 'display', 'none')
);
// show 2 previous buttons and 2 next buttons
const renderElements = 2;
const endDots = newIndex < this.buttonsRef.length - renderElements - 1;
const startDots = newIndex - renderElements > 0;
const firstButton = this.buttonsRef[0];
const lastButton = this.buttonsRef[this.buttonsRef.length - 1];
// last bubble and dots
this.ren.setStyle(this.dotsEndRef, 'display', endDots ? 'block' : 'none');
this.ren.setStyle(lastButton, 'display', endDots ? 'flex' : 'none');
// first bubble and dots
this.ren.setStyle(
this.dotsStartRef,
'display',
startDots ? 'block' : 'none'
);
this.ren.setStyle(firstButton, 'display', startDots ? 'flex' : 'none');
// resolve starting and ending index to show buttons
const startingIndex = startDots ? newIndex - renderElements : 0;
const endingIndex = endDots
? newIndex + renderElements
: this.buttonsRef.length - 1;
// display starting buttons
for (let i = startingIndex; i <= endingIndex; i++) {
const button = this.buttonsRef[i];
this.ren.setStyle(button, 'display', 'flex');
}
}
Выглядит пугающе, правда? Не стоит беспокоиться. Давайте разберем по шагам, что происходит в этой функции, и поможем вам разобраться в этой ерунде, чтобы потом иметь возможность ее изменить.
Во-первых, мы хотим подписаться на наблюдаемую [this.matPag.page](http://this.matPag.page)
и получить индекс предыдущей и новой пагинации. Наблюдаемая переменная срабатывает при каждом нажатии на кнопку пузырька, поскольку на предыдущем шаге мы прикрепили функцию switchPage()
к каждому пузырьку.
В функции changeActiveButtonStyles()
происходит следующее:
- мы меняем
g-bubble__active
из предыдущего активного пузырька на новый, который подсвечивает текущую активную кнопку, по указанным новым индексам в функции - по умолчанию скрываются все кнопки
(display: none)
- определяем, показывать ли конечные точки и последнюю кнопку по
endDots
, еслиnewIndex
не является 2 последними кнопками - определяем, показывать ли стартовые точки в начале первой кнопки по
startDots
, еслиnewIndex
больше 2 - вычисляем индексы для показа кнопок из
buttonsRef
, чтобы показать 2 предыдущие кнопки(startingIndex)
и 2 последующие(endingIndex)
- изменяем значение отображения для кнопок, которые должны быть видимыми
Итоги
В этой статье описывается, как изменить стилистику компонента MatPaginator в Angular Material для создания пользовательского интерфейса пагинации. В статье рассматривается создание пользовательской директивы, модификация стандартного макета, вставка div между кнопками навигации, рендеринг кнопок в DOM и прослушивание нажатий на кнопки навигации пользователем.
В итоге получился пользовательский интерфейс пагинации с кнопками-пузырьками и точками между первой и последней кнопками. Весь исходный код этого примера доступен на StackBlitz.
Надеюсь, вам понравился этот пост!