Feat: Replace pagination bar with numbered page buttons and chevron controls
Adds « ‹ [1][2][3][4] › » navigation to the library. Page window slides to keep the current page in view. Prev/next/first/last controls are always rendered but disabled at their respective bounds. Also wires up karmaConfig in angular.json so FirefoxHeadless is used for tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -155,15 +155,72 @@ describe('LibraryComponent', () => {
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
// ---- Pagination: US1 ----
|
||||
// ---- Pagination: page window (T002) ----
|
||||
|
||||
it('page indicator shows "Page 1 of 2" when totalPages > 1', () => {
|
||||
it('pageWindow returns [1,2,3,4] on page 1 of 20', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
fixture.componentInstance.currentPage = 1;
|
||||
fixture.componentInstance.totalPages = 20;
|
||||
expect(fixture.componentInstance.pageWindow).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('pageWindow returns [17,18,19,20] on page 20 of 20', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
fixture.componentInstance.currentPage = 20;
|
||||
fixture.componentInstance.totalPages = 20;
|
||||
expect(fixture.componentInstance.pageWindow).toEqual([17, 18, 19, 20]);
|
||||
});
|
||||
|
||||
it('pageWindow returns [6,7,8,9] on page 7 of 20', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
fixture.componentInstance.currentPage = 7;
|
||||
fixture.componentInstance.totalPages = 20;
|
||||
expect(fixture.componentInstance.pageWindow).toEqual([6, 7, 8, 9]);
|
||||
});
|
||||
|
||||
it('pageWindow returns all pages when totalPages < 4', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
fixture.componentInstance.currentPage = 2;
|
||||
fixture.componentInstance.totalPages = 3;
|
||||
expect(fixture.componentInstance.pageWindow).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('pageWindow returns [1] when totalPages is 1', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
fixture.componentInstance.currentPage = 1;
|
||||
fixture.componentInstance.totalPages = 1;
|
||||
expect(fixture.componentInstance.pageWindow).toEqual([1]);
|
||||
});
|
||||
|
||||
it('numbered page buttons are rendered (T002)', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
const indicator = (fixture.nativeElement as HTMLElement).querySelector('.page-indicator');
|
||||
expect(indicator?.textContent).toContain('Page 1 of 2');
|
||||
const pageBtns = (fixture.nativeElement as HTMLElement).querySelectorAll('.page-btn');
|
||||
expect(pageBtns.length).toBe(2); // 2 total pages
|
||||
expect(pageBtns[0].textContent?.trim()).toBe('1');
|
||||
expect(pageBtns[1].textContent?.trim()).toBe('2');
|
||||
});
|
||||
|
||||
it('active page button has .active class', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
const activeBtn = (fixture.nativeElement as HTMLElement).querySelector('.page-btn.active');
|
||||
expect(activeBtn).not.toBeNull();
|
||||
expect(activeBtn?.textContent?.trim()).toBe('1');
|
||||
});
|
||||
|
||||
it('goToPage() calls imageService.list with correct offset', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
listSpy.calls.reset();
|
||||
fixture.componentInstance.goToPage(2);
|
||||
expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 24);
|
||||
});
|
||||
|
||||
it('total count renders with correct number', () => {
|
||||
@@ -175,32 +232,6 @@ describe('LibraryComponent', () => {
|
||||
expect(el?.textContent).toContain('48');
|
||||
});
|
||||
|
||||
it('"Next" button present when not on last page', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.next-btn')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('"Previous" button absent on first page', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.prev-btn')).toBeNull();
|
||||
});
|
||||
|
||||
it('"Previous" present and "Next" absent on last page', () => {
|
||||
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.prev-btn')).not.toBeNull();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.next-btn')).toBeNull();
|
||||
});
|
||||
|
||||
it('no pagination controls when all images fit on one page', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
@@ -242,7 +273,58 @@ describe('LibraryComponent', () => {
|
||||
expect(listSpy).toHaveBeenCalledWith(['cat'], jasmine.any(Number), 0);
|
||||
});
|
||||
|
||||
// ---- Pagination: US2 — URL state ----
|
||||
// ---- Pagination: ‹ › disabled states (T006) ----
|
||||
|
||||
it('prev-btn (‹) is disabled on page 1', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
const prevBtn = (fixture.nativeElement as HTMLElement).querySelector('.prev-btn') as HTMLButtonElement;
|
||||
expect(prevBtn).not.toBeNull();
|
||||
expect(prevBtn.disabled).toBeTrue();
|
||||
});
|
||||
|
||||
it('next-btn (›) is disabled on last page', () => {
|
||||
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
const nextBtn = (fixture.nativeElement as HTMLElement).querySelector('.next-btn') as HTMLButtonElement;
|
||||
expect(nextBtn).not.toBeNull();
|
||||
expect(nextBtn.disabled).toBeTrue();
|
||||
});
|
||||
|
||||
it('prev-btn (‹) is enabled when not on page 1', () => {
|
||||
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
const prevBtn = (fixture.nativeElement as HTMLElement).querySelector('.prev-btn') as HTMLButtonElement;
|
||||
expect(prevBtn.disabled).toBeFalse();
|
||||
});
|
||||
|
||||
it('next-btn (›) is enabled when not on last page', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
const nextBtn = (fixture.nativeElement as HTMLElement).querySelector('.next-btn') as HTMLButtonElement;
|
||||
expect(nextBtn.disabled).toBeFalse();
|
||||
});
|
||||
|
||||
it('both prev and next buttons always rendered when totalPages > 1', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.prev-btn')).not.toBeNull();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('.next-btn')).not.toBeNull();
|
||||
});
|
||||
|
||||
// ---- Pagination: URL state ----
|
||||
|
||||
it('reads ?page=2 from queryParamMap on init and calls list with offset=24', () => {
|
||||
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
|
||||
@@ -260,7 +342,6 @@ describe('LibraryComponent', () => {
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
// After load, totalPages=2, currentPage should be clamped to 2 (not 9999), then router corrects URL
|
||||
expect(fixture.componentInstance.currentPage).toBeLessThanOrEqual(fixture.componentInstance.totalPages);
|
||||
});
|
||||
|
||||
@@ -304,4 +385,69 @@ describe('LibraryComponent', () => {
|
||||
card.click();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/i', 'ShrtImg1']);
|
||||
});
|
||||
|
||||
// ---- Pagination: « » first/last (T008) ----
|
||||
|
||||
it('firstPage() navigates to page 1', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
fixture.componentInstance.currentPage = 2;
|
||||
fixture.componentInstance.totalPages = 2;
|
||||
listSpy.calls.reset();
|
||||
fixture.componentInstance.firstPage();
|
||||
expect(listSpy).toHaveBeenCalledWith(jasmine.any(Array), jasmine.any(Number), 0);
|
||||
expect(fixture.componentInstance.currentPage).toBe(1);
|
||||
});
|
||||
|
||||
it('lastPage() navigates to last page', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
const listSpy = spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
listSpy.calls.reset();
|
||||
fixture.componentInstance.lastPage();
|
||||
expect(fixture.componentInstance.currentPage).toBe(fixture.componentInstance.totalPages);
|
||||
});
|
||||
|
||||
it('first-page button («) is disabled on page 1', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
const firstBtn = (fixture.nativeElement as HTMLElement).querySelector('.first-btn') as HTMLButtonElement;
|
||||
expect(firstBtn).not.toBeNull();
|
||||
expect(firstBtn.disabled).toBeTrue();
|
||||
});
|
||||
|
||||
it('last-page button (») is disabled on last page', () => {
|
||||
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
const lastBtn = (fixture.nativeElement as HTMLElement).querySelector('.last-btn') as HTMLButtonElement;
|
||||
expect(lastBtn).not.toBeNull();
|
||||
expect(lastBtn.disabled).toBeTrue();
|
||||
});
|
||||
|
||||
it('first-page button («) is enabled when not on page 1', () => {
|
||||
TestBed.overrideProvider(ActivatedRoute, { useValue: makeActivatedRoute({ page: '2' }) });
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
const firstBtn = (fixture.nativeElement as HTMLElement).querySelector('.first-btn') as HTMLButtonElement;
|
||||
expect(firstBtn.disabled).toBeFalse();
|
||||
});
|
||||
|
||||
it('last-page button (») is enabled when not on last page', () => {
|
||||
const fixture = TestBed.createComponent(LibraryComponent);
|
||||
const imgSvc = TestBed.inject(ImageService);
|
||||
spyOn(imgSvc, 'list').and.returnValue(of(MULTI_PAGE));
|
||||
fixture.detectChanges();
|
||||
const lastBtn = (fixture.nativeElement as HTMLElement).querySelector('.last-btn') as HTMLButtonElement;
|
||||
expect(lastBtn.disabled).toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,9 +90,17 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
||||
|
||||
<!-- Pagination controls — only when more than one page -->
|
||||
<div *ngIf="totalPages > 1 && !showSpinner && !error" class="pagination-bar">
|
||||
<button *ngIf="currentPage > 1" class="prev-btn" (click)="prevPage()">← Previous</button>
|
||||
<span class="page-indicator">Page {{ currentPage }} of {{ totalPages }}</span>
|
||||
<button *ngIf="currentPage < totalPages" class="next-btn" (click)="nextPage()">Next →</button>
|
||||
<button class="pag-btn first-btn" [disabled]="currentPage === 1" (click)="firstPage()" aria-label="First page">«</button>
|
||||
<button class="pag-btn prev-btn" [disabled]="currentPage === 1" (click)="prevPage()" aria-label="Previous page">‹</button>
|
||||
<button
|
||||
*ngFor="let p of pageWindow"
|
||||
class="pag-btn page-btn"
|
||||
[class.active]="p === currentPage"
|
||||
(click)="goToPage(p)"
|
||||
[attr.aria-current]="p === currentPage ? 'page' : null"
|
||||
>{{ p }}</button>
|
||||
<button class="pag-btn next-btn" [disabled]="currentPage === totalPages" (click)="nextPage()" aria-label="Next page">›</button>
|
||||
<button class="pag-btn last-btn" [disabled]="currentPage === totalPages" (click)="lastPage()" aria-label="Last page">»</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
@@ -130,10 +138,11 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/s
|
||||
.retry-btn { padding: 8px 24px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: border-color var(--transition); }
|
||||
.retry-btn:hover { border-color: var(--border-focus); }
|
||||
.total-count { text-align: center; color: var(--text-muted); font-size: 0.85rem; margin: 16px 0 8px; }
|
||||
.pagination-bar { display: flex; justify-content: center; align-items: center; gap: 16px; margin: 16px 0 24px; }
|
||||
.prev-btn, .next-btn { padding: 8px 20px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: border-color var(--transition); }
|
||||
.prev-btn:hover, .next-btn:hover { border-color: var(--border-focus); }
|
||||
.page-indicator { color: var(--text-muted); font-size: 0.9rem; }
|
||||
.pagination-bar { display: flex; justify-content: center; align-items: center; gap: 6px; margin: 16px 0 24px; flex-wrap: wrap; }
|
||||
.pag-btn { min-width: 36px; height: 36px; padding: 0 10px; background: var(--surface-raised); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; font-size: 0.95rem; transition: border-color var(--transition), background var(--transition); }
|
||||
.pag-btn:hover:not(:disabled) { border-color: var(--border-focus); }
|
||||
.pag-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.page-btn.active { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
|
||||
`],
|
||||
})
|
||||
export class LibraryComponent implements OnInit {
|
||||
@@ -225,6 +234,37 @@ export class LibraryComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
get pageWindow(): number[] {
|
||||
let start = Math.max(1, this.currentPage - 1);
|
||||
const end = Math.min(this.totalPages, start + 3);
|
||||
start = Math.max(1, end - 3);
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
||||
}
|
||||
|
||||
goToPage(page: number): void {
|
||||
if (page >= 1 && page <= this.totalPages && page !== this.currentPage) {
|
||||
this.currentPage = page;
|
||||
this.router.navigate([], { queryParams: { page: this.currentPage }, queryParamsHandling: 'merge' });
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
firstPage(): void {
|
||||
if (this.currentPage !== 1) {
|
||||
this.currentPage = 1;
|
||||
this.router.navigate([], { queryParams: { page: 1 }, queryParamsHandling: 'merge' });
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
lastPage(): void {
|
||||
if (this.currentPage !== this.totalPages) {
|
||||
this.currentPage = this.totalPages;
|
||||
this.router.navigate([], { queryParams: { page: this.totalPages }, queryParamsHandling: 'merge' });
|
||||
this.load();
|
||||
}
|
||||
}
|
||||
|
||||
nextPage(): void {
|
||||
if (this.currentPage < this.totalPages) {
|
||||
this.currentPage++;
|
||||
|
||||
Reference in New Issue
Block a user