feat: add Paginator component with navigation functionality and integrate into reutilizables test page
This commit is contained in:
56
src/app/shared/components/paginator/paginator.component.html
Normal file
56
src/app/shared/components/paginator/paginator.component.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<nav
|
||||
class="d-inline-flex align-items-center justify-content-center gap-3 text-decoration-none"
|
||||
aria-label="Paginacion"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="paginator__button btn btn-link d-inline-flex align-items-center justify-content-center rounded-circle p-0 text-decoration-none"
|
||||
data-testid="paginator-first"
|
||||
aria-label="Ir a la primera pagina"
|
||||
[disabled]="isFirstStepDisabled()"
|
||||
(click)="changePage('first')"
|
||||
>
|
||||
<i class="fa-solid fa-angles-left" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="paginator__button btn btn-link d-inline-flex align-items-center justify-content-center rounded-circle p-0 text-decoration-none"
|
||||
data-testid="paginator-previous"
|
||||
aria-label="Ir a la pagina anterior"
|
||||
[disabled]="isFirstStepDisabled()"
|
||||
(click)="changePage('previous')"
|
||||
>
|
||||
<i class="fa-solid fa-angle-left" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<span
|
||||
class="paginator__status d-inline-block text-center fw-semibold lh-1"
|
||||
data-testid="paginator-status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ displayPage() }}/{{ displayTotalPages() }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="paginator__button btn btn-link d-inline-flex align-items-center justify-content-center rounded-circle p-0 text-decoration-none"
|
||||
data-testid="paginator-next"
|
||||
aria-label="Ir a la pagina siguiente"
|
||||
[disabled]="isLastStepDisabled()"
|
||||
(click)="changePage('next')"
|
||||
>
|
||||
<i class="fa-solid fa-angle-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="paginator__button btn btn-link d-inline-flex align-items-center justify-content-center rounded-circle p-0 text-decoration-none"
|
||||
data-testid="paginator-last"
|
||||
aria-label="Ir a la ultima pagina"
|
||||
[disabled]="isLastStepDisabled()"
|
||||
(click)="changePage('last')"
|
||||
>
|
||||
<i class="fa-solid fa-angles-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
</nav>
|
||||
37
src/app/shared/components/paginator/paginator.component.scss
Normal file
37
src/app/shared/components/paginator/paginator.component.scss
Normal file
@@ -0,0 +1,37 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
color: var(--tenant-primary);
|
||||
}
|
||||
|
||||
.paginator__button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
color: inherit;
|
||||
font-size: 1rem;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: color-mix(in srgb, white 84%, var(--tenant-primary));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, white 55%, var(--tenant-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: color-mix(in srgb, white 45%, var(--tenant-primary));
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.paginator__status {
|
||||
min-width: 3rem;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PaginatorComponent } from './paginator.component';
|
||||
|
||||
describe('PaginatorComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PaginatorComponent]
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
function setup(page: number, totalPages: number, disabled = false) {
|
||||
const fixture = TestBed.createComponent(PaginatorComponent);
|
||||
fixture.componentRef.setInput('page', page);
|
||||
fixture.componentRef.setInput('totalPages', totalPages);
|
||||
fixture.componentRef.setInput('disabled', disabled);
|
||||
fixture.detectChanges();
|
||||
|
||||
return {
|
||||
fixture,
|
||||
element: fixture.nativeElement as HTMLElement
|
||||
};
|
||||
}
|
||||
|
||||
it('renders the current page status', () => {
|
||||
const { element } = setup(1, 8);
|
||||
|
||||
expect(element.querySelector('[data-testid="paginator-status"]')?.textContent?.trim()).toBe('1/8');
|
||||
});
|
||||
|
||||
it('emits the next page when clicking next', () => {
|
||||
const { fixture, element } = setup(1, 8);
|
||||
const pageChangeSpy = vi.fn();
|
||||
|
||||
fixture.componentInstance.pageChange.subscribe(pageChangeSpy);
|
||||
(element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click();
|
||||
|
||||
expect(pageChangeSpy).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('emits the last page when clicking last', () => {
|
||||
const { fixture, element } = setup(1, 8);
|
||||
const pageChangeSpy = vi.fn();
|
||||
|
||||
fixture.componentInstance.pageChange.subscribe(pageChangeSpy);
|
||||
(element.querySelector('[data-testid="paginator-last"]') as HTMLButtonElement).click();
|
||||
|
||||
expect(pageChangeSpy).toHaveBeenCalledWith(8);
|
||||
});
|
||||
|
||||
it('emits the first page when clicking first from a middle page', () => {
|
||||
const { fixture, element } = setup(4, 8);
|
||||
const pageChangeSpy = vi.fn();
|
||||
|
||||
fixture.componentInstance.pageChange.subscribe(pageChangeSpy);
|
||||
(element.querySelector('[data-testid="paginator-first"]') as HTMLButtonElement).click();
|
||||
|
||||
expect(pageChangeSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does not emit when trying to go before the first page', () => {
|
||||
const { fixture, element } = setup(1, 8);
|
||||
const pageChangeSpy = vi.fn();
|
||||
|
||||
fixture.componentInstance.pageChange.subscribe(pageChangeSpy);
|
||||
(element.querySelector('[data-testid="paginator-previous"]') as HTMLButtonElement).click();
|
||||
|
||||
expect(pageChangeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not emit when trying to go after the last page', () => {
|
||||
const { fixture, element } = setup(8, 8);
|
||||
const pageChangeSpy = vi.fn();
|
||||
|
||||
fixture.componentInstance.pageChange.subscribe(pageChangeSpy);
|
||||
(element.querySelector('[data-testid="paginator-next"]') as HTMLButtonElement).click();
|
||||
|
||||
expect(pageChangeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables all buttons when disabled is true', () => {
|
||||
const { element } = setup(2, 8, true);
|
||||
|
||||
expect(Array.from(element.querySelectorAll('button')).every((button) => button.disabled)).toBe(true);
|
||||
});
|
||||
|
||||
it('disables all buttons when there is only one page', () => {
|
||||
const { element } = setup(1, 1);
|
||||
|
||||
expect(Array.from(element.querySelectorAll('button')).every((button) => button.disabled)).toBe(true);
|
||||
});
|
||||
});
|
||||
76
src/app/shared/components/paginator/paginator.component.ts
Normal file
76
src/app/shared/components/paginator/paginator.component.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||
|
||||
type PaginatorAction = 'first' | 'previous' | 'next' | 'last';
|
||||
|
||||
@Component({
|
||||
selector: 'app-paginator',
|
||||
standalone: true,
|
||||
templateUrl: './paginator.component.html',
|
||||
styleUrl: './paginator.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PaginatorComponent {
|
||||
readonly page = input.required<number>();
|
||||
readonly totalPages = input.required<number>();
|
||||
readonly disabled = input(false);
|
||||
|
||||
readonly pageChange = output<number>();
|
||||
|
||||
protected readonly displayPage = computed(() => {
|
||||
const totalPages = this.displayTotalPages();
|
||||
|
||||
if (totalPages < 1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(this.page(), 1), totalPages);
|
||||
});
|
||||
|
||||
protected readonly displayTotalPages = computed(() => Math.max(this.totalPages(), 0));
|
||||
|
||||
protected readonly isNavigationDisabled = computed(() => {
|
||||
const page = this.page();
|
||||
const totalPages = this.totalPages();
|
||||
|
||||
return (
|
||||
this.disabled() ||
|
||||
!Number.isInteger(page) ||
|
||||
!Number.isInteger(totalPages) ||
|
||||
totalPages <= 1 ||
|
||||
page < 1 ||
|
||||
page > totalPages
|
||||
);
|
||||
});
|
||||
|
||||
protected readonly isFirstStepDisabled = computed(
|
||||
() => this.isNavigationDisabled() || this.displayPage() <= 1
|
||||
);
|
||||
|
||||
protected readonly isLastStepDisabled = computed(
|
||||
() => this.isNavigationDisabled() || this.displayPage() >= this.displayTotalPages()
|
||||
);
|
||||
|
||||
protected changePage(action: PaginatorAction): void {
|
||||
if (this.isNavigationDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPage = this.displayPage();
|
||||
const totalPages = this.displayTotalPages();
|
||||
|
||||
const nextPageByAction: Record<PaginatorAction, number> = {
|
||||
first: 1,
|
||||
previous: currentPage - 1,
|
||||
next: currentPage + 1,
|
||||
last: totalPages
|
||||
};
|
||||
|
||||
const nextPage = nextPageByAction[action];
|
||||
|
||||
if (nextPage === currentPage || nextPage < 1 || nextPage > totalPages) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pageChange.emit(nextPage);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user