335 lines
8.5 KiB
TypeScript
335 lines
8.5 KiB
TypeScript
import { Component, input, output } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
import { MatIconModule } from '@angular/material/icon';
|
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
|
import { MatSelectModule } from '@angular/material/select';
|
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
import { PaginationState } from '../../../core/services/pagination.service';
|
|
|
|
/**
|
|
* Pagination Component
|
|
* Reusable pagination controls with customizable options
|
|
*
|
|
* Features:
|
|
* - Previous/Next buttons
|
|
* - First/Last page buttons
|
|
* - Page number buttons with active state
|
|
* - "Showing X-Y of Z results" display
|
|
* - Page size selector
|
|
* - Responsive design (fewer buttons on mobile)
|
|
* - Accessible with keyboard navigation and ARIA labels
|
|
*
|
|
* @example
|
|
* <app-pagination
|
|
* [state]="paginationState()"
|
|
* [pageSizeOptions]="[10, 25, 50, 100]"
|
|
* [showPageSizeSelector]="true"
|
|
* [showFirstLast]="true"
|
|
* [maxVisiblePages]="5"
|
|
* (pageChange)="onPageChange($event)"
|
|
* (pageSizeChange)="onPageSizeChange($event)">
|
|
* </app-pagination>
|
|
*/
|
|
@Component({
|
|
selector: 'app-pagination',
|
|
imports: [
|
|
CommonModule,
|
|
MatButtonModule,
|
|
MatIconModule,
|
|
MatTooltipModule,
|
|
MatSelectModule,
|
|
MatFormFieldModule
|
|
],
|
|
template: `
|
|
<div class="pagination-container" *ngIf="state()">
|
|
<!-- Results info -->
|
|
<div class="pagination-info">
|
|
<span class="info-text">
|
|
Showing <strong>{{ state()!.startIndex }}</strong>
|
|
to <strong>{{ state()!.endIndex }}</strong>
|
|
of <strong>{{ state()!.totalItems }}</strong> {{ itemLabel() }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="pagination-actions">
|
|
<!-- Page size selector -->
|
|
@if (showPageSizeSelector()) {
|
|
<mat-form-field class="page-size-selector" appearance="outline">
|
|
<mat-select
|
|
[value]="state()!.itemsPerPage"
|
|
(selectionChange)="onPageSizeChange($event.value)"
|
|
aria-label="Items per page">
|
|
@for (option of pageSizeOptions(); track option) {
|
|
<mat-option [value]="option">{{ option }} per page</mat-option>
|
|
}
|
|
</mat-select>
|
|
</mat-form-field>
|
|
}
|
|
|
|
<!-- Pagination controls -->
|
|
<div class="pagination-controls">
|
|
<!-- First page -->
|
|
@if (showFirstLast()) {
|
|
<button
|
|
mat-icon-button
|
|
(click)="onPageChange(1)"
|
|
[disabled]="!state()!.hasPreviousPage"
|
|
matTooltip="First page"
|
|
aria-label="Go to first page">
|
|
<mat-icon>first_page</mat-icon>
|
|
</button>
|
|
}
|
|
|
|
<!-- Previous page -->
|
|
<button
|
|
mat-icon-button
|
|
(click)="onPageChange(state()!.currentPage - 1)"
|
|
[disabled]="!state()!.hasPreviousPage"
|
|
matTooltip="Previous page"
|
|
aria-label="Go to previous page">
|
|
<mat-icon>chevron_left</mat-icon>
|
|
</button>
|
|
|
|
<!-- Page numbers -->
|
|
<div class="page-numbers">
|
|
@for (page of pageNumbers(); track page) {
|
|
@if (page === '...') {
|
|
<span class="ellipsis">{{ page }}</span>
|
|
} @else {
|
|
<button
|
|
mat-button
|
|
class="page-button"
|
|
[class.active]="page === state()!.currentPage"
|
|
(click)="handlePageClick(page)"
|
|
[attr.aria-label]="'Go to page ' + page"
|
|
[attr.aria-current]="page === state()!.currentPage ? 'page' : null">
|
|
{{ page }}
|
|
</button>
|
|
}
|
|
}
|
|
</div>
|
|
|
|
<!-- Next page -->
|
|
<button
|
|
mat-icon-button
|
|
(click)="onPageChange(state()!.currentPage + 1)"
|
|
[disabled]="!state()!.hasNextPage"
|
|
matTooltip="Next page"
|
|
aria-label="Go to next page">
|
|
<mat-icon>chevron_right</mat-icon>
|
|
</button>
|
|
|
|
<!-- Last page -->
|
|
@if (showFirstLast()) {
|
|
<button
|
|
mat-icon-button
|
|
(click)="onPageChange(state()!.totalPages)"
|
|
[disabled]="!state()!.hasNextPage"
|
|
matTooltip="Last page"
|
|
aria-label="Go to last page">
|
|
<mat-icon>last_page</mat-icon>
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
styles: [`
|
|
.pagination-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
padding: 1rem;
|
|
background: var(--surface-color, #fff);
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.pagination-info {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.info-text {
|
|
font-size: 0.875rem;
|
|
color: var(--text-secondary, #666);
|
|
}
|
|
|
|
.info-text strong {
|
|
color: var(--text-primary, #333);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.pagination-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.page-size-selector {
|
|
min-width: 140px;
|
|
}
|
|
|
|
.page-size-selector ::ng-deep .mat-mdc-form-field-subscript-wrapper {
|
|
display: none;
|
|
}
|
|
|
|
.pagination-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.page-numbers {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.page-button {
|
|
min-width: 40px;
|
|
height: 40px;
|
|
padding: 0 8px;
|
|
border-radius: 4px;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.page-button:hover {
|
|
background-color: var(--hover-color, rgba(0, 0, 0, 0.04));
|
|
}
|
|
|
|
.page-button.active {
|
|
background-color: var(--primary-color, #1976d2);
|
|
color: white;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.page-button.active:hover {
|
|
background-color: var(--primary-dark, #1565c0);
|
|
}
|
|
|
|
.ellipsis {
|
|
padding: 0 8px;
|
|
color: var(--text-secondary, #666);
|
|
user-select: none;
|
|
}
|
|
|
|
/* Responsive design */
|
|
@media (max-width: 768px) {
|
|
.pagination-container {
|
|
padding: 0.75rem;
|
|
}
|
|
|
|
.pagination-actions {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.page-size-selector {
|
|
width: 100%;
|
|
}
|
|
|
|
.pagination-controls {
|
|
justify-content: center;
|
|
}
|
|
|
|
.page-numbers {
|
|
gap: 0.125rem;
|
|
}
|
|
|
|
.page-button {
|
|
min-width: 36px;
|
|
height: 36px;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
/* Hide ellipsis on very small screens */
|
|
@media (max-width: 480px) {
|
|
.ellipsis {
|
|
display: none;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Dark mode support */
|
|
@media (prefers-color-scheme: dark) {
|
|
.pagination-container {
|
|
background: var(--surface-dark, #1e1e1e);
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.info-text {
|
|
color: var(--text-secondary-dark, #b0b0b0);
|
|
}
|
|
|
|
.info-text strong {
|
|
color: var(--text-primary-dark, #e0e0e0);
|
|
}
|
|
|
|
.page-button:hover {
|
|
background-color: rgba(255, 255, 255, 0.08);
|
|
}
|
|
|
|
.ellipsis {
|
|
color: var(--text-secondary-dark, #b0b0b0);
|
|
}
|
|
}
|
|
|
|
/* Focus styles for accessibility */
|
|
.page-button:focus-visible,
|
|
button:focus-visible {
|
|
outline: 2px solid var(--primary-color, #1976d2);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
/* Disabled state */
|
|
button:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
`]
|
|
})
|
|
export class PaginationComponent {
|
|
// Input signals
|
|
state = input.required<PaginationState | null>();
|
|
pageNumbers = input<(number | string)[]>([]);
|
|
pageSizeOptions = input<number[]>([10, 25, 50, 100]);
|
|
showPageSizeSelector = input<boolean>(true);
|
|
showFirstLast = input<boolean>(true);
|
|
maxVisiblePages = input<number>(5);
|
|
itemLabel = input<string>('results');
|
|
|
|
// Output events
|
|
pageChange = output<number>();
|
|
pageSizeChange = output<number>();
|
|
|
|
/**
|
|
* Handle page change
|
|
*/
|
|
onPageChange(page: number): void {
|
|
if (page >= 1 && page <= (this.state()?.totalPages ?? 1)) {
|
|
this.pageChange.emit(page);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle page button click (for page numbers)
|
|
*/
|
|
handlePageClick(page: number | string): void {
|
|
if (typeof page === 'number') {
|
|
this.onPageChange(page);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle page size change
|
|
*/
|
|
onPageSizeChange(pageSize: number): void {
|
|
this.pageSizeChange.emit(pageSize);
|
|
}
|
|
}
|