DevGang
Авторизоваться

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), то мы:
  1. отрисовываем первую кнопку
  2. отрисовываем точки
  3. отрисовываем остальные кнопки, кроме последней
  4. отрисовываем точки
  5. отрисовываем последнюю кнопку
  • сохраняем кнопки из 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.

Надеюсь, вам понравился этот пост!

Источник:

#Angular
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу