Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: Advanced pagination #245

Closed
2 tasks
DevWedeloper opened this issue Apr 2, 2024 · 3 comments · Fixed by #372
Closed
2 tasks

RFC: Advanced pagination #245

DevWedeloper opened this issue Apr 2, 2024 · 3 comments · Fixed by #372
Labels
enhancement New feature or request

Comments

@DevWedeloper
Copy link
Contributor

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

  • Yes
  • No
@DevWedeloper DevWedeloper added the enhancement New feature or request label Apr 2, 2024
@eneajaho
Copy link
Contributor

eneajaho commented Jul 6, 2024

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

And it looks something like this:

CleanShot.2024-07-06.at.21.33.27.mp4

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;
  }
}

@DevWedeloper
Copy link
Contributor Author

I think adding it to the docs would be a great idea, I could add it myself (the example above) if you guys are fine with it. Also should we have just one example, or we could expand to having multiple examples?

@goetzrobin
Copy link
Collaborator

@DevWedeloper @eneajaho We can also add it as part of hlm so people can copy it into their project using the CLI. Thoughts?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants