goetzrobin / spartan

Cutting-edge tools powering Angular full-stack development.
https://spartan.ng
MIT License
1.1k stars 121 forks source link

RFC: Advanced pagination #245

Open DevWedeloper opened 3 months ago

DevWedeloper commented 3 months ago

Which scope/s are relevant/related to the feature request?

pagination

Information

I believe it would be beneficial to have a pagination component similar to that of ng-zorros.

Describe any alternatives/workarounds you're currently using

Implementing a similar pagination logic manually.

I would be willing to submit a PR to fix this issue

eneajaho commented 1 day ago

Hi, I needed something like this, and copied the implementation from ngx-pagination and used spartan UI components.

And it looks something like this:

https://github.com/goetzrobin/spartan/assets/25394362/170a8c02-adb4-4b86-9ab6-9943d2c98ccd

We can also add it as an example in the docs for other devs to use ofc (but I haven't had any free time)

import {
  ChangeDetectionStrategy,
  Component,
  input,
  model,
  computed,
  untracked,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
  HlmPaginationContentDirective,
  HlmPaginationDirective,
  HlmPaginationEllipsisComponent,
  HlmPaginationItemDirective,
  HlmPaginationLinkDirective,
  HlmPaginationNextComponent,
  HlmPaginationPreviousComponent,
} from '@spartan-ng/ui-pagination-helm';
import { BrnSelectImports } from '@spartan-ng/ui-select-brain';
import { HlmSelectImports } from '@spartan-ng/ui-select-helm';

@Component({
  selector: 'app-numbered-pagination',
  template: `
    <div class="flex items-center justify-between gap-2 px-4 py-2">
      <div class="flex items-center text-nowrap gap-1 text-sm text-gray-600">
        <b>{{ totalItems() }}</b> total items | <b>{{ pages().length }}</b>
        pages
      </div>

      <nav hlmPagination>
        <ul hlmPaginationContent>
          @if (showEdges() && !isFirstPageActive()) {
            <li hlmPaginationItem (click)="goToPrevious()">
              <hlm-pagination-previous />
            </li>
          }

          @for (page of pages(); track page) {
            <li hlmPaginationItem>
              @if (page === '...') {
                <hlm-pagination-ellipsis />
              } @else {
                <a
                  hlmPaginationLink
                  [isActive]="currentPage() === page"
                  (click)="currentPage.set(page)">
                  {{ page }}
                </a>
              }
            </li>
          }

          @if (showEdges() && !isLastPageActive()) {
            <li hlmPaginationItem (click)="goToNext()">
              <hlm-pagination-next />
            </li>
          }
        </ul>
      </nav>

      <!-- Show Page Size selector -->
      <brn-select
        [(ngModel)]="itemsPerPage"
        class="ml-auto"
        placeholder="Page size">
        <hlm-select-trigger class="w-fit">
          <hlm-select-value />
        </hlm-select-trigger>
        <hlm-select-content>
          @for (pageSize of pageSizesWithCurrent(); track pageSize) {
            <hlm-option [value]="pageSize">{{ pageSize }} / page</hlm-option>
          }
        </hlm-select-content>
      </brn-select>
    </div>
  `,
  standalone: true,
  imports: [
    FormsModule,
    HlmPaginationDirective,
    HlmPaginationContentDirective,
    HlmPaginationItemDirective,
    HlmPaginationPreviousComponent,
    HlmPaginationNextComponent,
    HlmPaginationLinkDirective,
    HlmPaginationEllipsisComponent,
    BrnSelectImports,
    HlmSelectImports,
  ],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NumberedPaginationComponent {
  /**
   * The current (active) page.
   */
  currentPage = model.required<number>();

  /**
   * The number of items per paginated page.
   */
  itemsPerPage = model.required<number>();

  /**
   * The total number of items in the collection. Only useful when
   * doing server-side paging, where the collection size is limited
   * to a single page returned by the server API.
   */
  totalItems = input.required<number>();

  /**
   * The number of page links to show.
   */
  maxSize = input(7);

  /**
   * Show the first and last page buttons.
   */
  showEdges = input(true);

  /**
   * The page sizes to show.
   * Defaults to [10, 20, 50, 100]
   */
  pageSizes = input([10, 20, 50, 100]);

  protected pageSizesWithCurrent = computed(() => {
    const pageSizes = this.pageSizes();
    return pageSizes.includes(this.itemsPerPage())
      ? pageSizes // if current page size is included, return the same array
      : [...pageSizes, this.itemsPerPage()].sort((a, b) => a - b); // otherwise, add current page size and sort the array
  });

  protected isFirstPageActive = computed(() => this.currentPage() === 1);
  protected isLastPageActive = computed(
    () => this.currentPage() === this.lastPageNumber()
  );

  protected goToPrevious() {
    this.currentPage.set(this.currentPage() - 1);
  }

  protected goToNext() {
    this.currentPage.set(this.currentPage() + 1);
  }

  protected goToFirst() {
    this.currentPage.set(1);
  }

  protected goToLast() {
    this.currentPage.set(this.lastPageNumber());
  }

  protected lastPageNumber = computed(() => {
    if (this.totalItems() < 1) {
      // when there are 0 or fewer (an error case) items, there are no "pages" as such,
      // but it makes sense to consider a single, empty page as the last page.
      return 1;
    }
    return Math.ceil(this.totalItems() / this.itemsPerPage());
  });

  protected pages = computed(() => {
    const correctedCurrentPage = outOfBoundCorrection(
      this.totalItems(),
      this.itemsPerPage(),
      this.currentPage()
    );

    if (correctedCurrentPage !== this.currentPage()) {
      // update the current page
      untracked(() => this.currentPage.set(correctedCurrentPage));
    }

    return createPageArray(
      correctedCurrentPage,
      this.itemsPerPage(),
      this.totalItems(),
      this.maxSize()
    );
  });
}

type Page = number | '...';

/**
 * Checks that the instance.currentPage property is within bounds for the current page range.
 * If not, return a correct value for currentPage, or the current value if OK.
 *
 * Copied from 'ngx-pagination' package
 */
function outOfBoundCorrection(
  totalItems: number,
  itemsPerPage: number,
  currentPage: number
): number {
  const totalPages = Math.ceil(totalItems / itemsPerPage);
  if (totalPages < currentPage && 0 < totalPages) {
    return totalPages;
  } else if (currentPage < 1) {
    return 1;
  }

  return currentPage;
}

/**
 * Returns an array of Page objects to use in the pagination controls.
 *
 * Copied from 'ngx-pagination' package
 */
function createPageArray(
  currentPage: number,
  itemsPerPage: number,
  totalItems: number,
  paginationRange: number
): Page[] {
  // paginationRange could be a string if passed from attribute, so cast to number.
  paginationRange = +paginationRange;
  const pages: Page[] = [];

  // Return 1 as default page number
  // Make sense to show 1 instead of empty when there are no items
  const totalPages = Math.max(Math.ceil(totalItems / itemsPerPage), 1);
  const halfWay = Math.ceil(paginationRange / 2);

  const isStart = currentPage <= halfWay;
  const isEnd = totalPages - halfWay < currentPage;
  const isMiddle = !isStart && !isEnd;

  const ellipsesNeeded = paginationRange < totalPages;
  let i = 1;

  while (i <= totalPages && i <= paginationRange) {
    let label: number | '...';
    const pageNumber = calculatePageNumber(
      i,
      currentPage,
      paginationRange,
      totalPages
    );
    const openingEllipsesNeeded = i === 2 && (isMiddle || isEnd);
    const closingEllipsesNeeded =
      i === paginationRange - 1 && (isMiddle || isStart);
    if (ellipsesNeeded && (openingEllipsesNeeded || closingEllipsesNeeded)) {
      label = '...';
    } else {
      label = pageNumber;
    }
    pages.push(label);
    i++;
  }

  return pages;
}

/**
 * Given the position in the sequence of pagination links [i],
 * figure out what page number corresponds to that position.
 *
 * Copied from 'ngx-pagination' package
 */
function calculatePageNumber(
  i: number,
  currentPage: number,
  paginationRange: number,
  totalPages: number
) {
  const halfWay = Math.ceil(paginationRange / 2);
  if (i === paginationRange) {
    return totalPages;
  } else if (i === 1) {
    return i;
  } else if (paginationRange < totalPages) {
    if (totalPages - halfWay < currentPage) {
      return totalPages - paginationRange + i;
    } else if (halfWay < currentPage) {
      return currentPage - halfWay + i;
    } else {
      return i;
    }
  } else {
    return i;
  }
}