import type { BooleanInput, NumberInput } from '@angular/cdk/coercion'; import { ChangeDetectionStrategy, Component, booleanAttribute, computed, input, model, numberAttribute, untracked, } from '@angular/core'; import { HlmSelectImports } from '@spartan-ng/styles/select'; import { HlmPagination } from './hlm-pagination'; import { HlmPaginationContent } from './hlm-pagination-content'; import { HlmPaginationEllipsis } from './hlm-pagination-ellipsis'; import { HlmPaginationItem } from './hlm-pagination-item'; import { HlmPaginationLink } from './hlm-pagination-link'; import { HlmPaginationNext } from './hlm-pagination-next'; import { HlmPaginationPrevious } from './hlm-pagination-previous'; @Component({ selector: 'hlm-numbered-pagination', imports: [ HlmPagination, HlmPaginationContent, HlmPaginationItem, HlmPaginationPrevious, HlmPaginationNext, HlmPaginationLink, HlmPaginationEllipsis, HlmSelectImports, ], changeDetection: ChangeDetectionStrategy.OnPush, template: `
{{ totalItems() }} total items | {{ _lastPageNumber() }} pages
@for (pageSize of _pageSizesWithCurrent(); track pageSize) { {{ pageSize }} }
`, }) export class HlmNumberedPagination { /** * The current (active) page. */ public readonly currentPage = model.required(); /** * The number of items per paginated page. */ public readonly itemsPerPage = model.required(); /** * 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. */ public readonly totalItems = input.required({ transform: numberAttribute, }); /** * The number of page links to show. */ public readonly maxSize = input(7, { transform: numberAttribute, }); /** * Show the first and last page buttons. */ public readonly showEdges = input(true, { transform: booleanAttribute, }); /** * The page sizes to show. * Defaults to [10, 20, 50, 100] */ public readonly pageSizes = input([10, 20, 50, 100]); protected readonly _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 readonly _isFirstPageActive = computed(() => this.currentPage() === 1); protected readonly _isLastPageActive = computed( () => this.currentPage() === this._lastPageNumber(), ); protected readonly _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 readonly _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(), ); }); protected goToPrevious(): void { this.currentPage.set(this.currentPage() - 1); } protected goToNext(): void { this.currentPage.set(this.currentPage() + 1); } protected goToFirst(): void { this.currentPage.set(1); } protected goToLast(): void { this.currentPage.set(this._lastPageNumber()); } } 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 */ export function outOfBoundCorrection( totalItems: number, itemsPerPage: number, currentPage: number, ): number { const totalPages = Math.ceil(totalItems / itemsPerPage); if (totalPages < currentPage && 0 < totalPages) { return totalPages; } if (currentPage < 1) { return 1; } return currentPage; } /** * Returns an array of Page objects to use in the pagination controls. * * Copied from 'ngx-pagination' package */ export 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; } if (i === 1) { return i; } if (paginationRange < totalPages) { if (totalPages - halfWay < currentPage) { return totalPages - paginationRange + i; } if (halfWay < currentPage) { return currentPage - halfWay + i; } return i; } return i; }