diff --git a/playwright/cps-accessibility.spec.ts b/playwright/cps-accessibility.spec.ts index 60f60866b..97cecc415 100644 --- a/playwright/cps-accessibility.spec.ts +++ b/playwright/cps-accessibility.spec.ts @@ -170,7 +170,7 @@ const components: ComponentEntry[] = [ } } }, - // { route: '/paginator', name: 'Paginator', selector: 'cps-paginator' }, + { route: '/paginator', name: 'Paginator', selector: 'cps-paginator' }, { route: '/progress-circular', name: 'Progress circular', diff --git a/projects/composition/src/app/api-data/cps-paginator.json b/projects/composition/src/app/api-data/cps-paginator.json index 34e3c2b9e..0d66e7194 100644 --- a/projects/composition/src/app/api-data/cps-paginator.json +++ b/projects/composition/src/app/api-data/cps-paginator.json @@ -60,6 +60,14 @@ "type": "boolean", "default": "false", "description": "Determines whether to reset page index when the number of rows per page changes." + }, + { + "name": "ariaLabel", + "optional": false, + "readonly": false, + "type": "string", + "default": "Pagination", + "description": "Accessible label for the paginator component.\nFalls back to \"Pagination\" when empty value is provided." } ] }, diff --git a/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.html b/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.html index c42463196..bdecee49f 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.html @@ -3,18 +3,21 @@ (onPageChange)="onPageChange($event)" [first]="first" [rows]="rows" - [style]="{ background: backgroundColor }" + [style.background]="cvtBackgroundColor" [totalRecords]="totalRecords" [showFirstLastIcon]="true" [showCurrentPageReport]="true" [alwaysShow]="alwaysShow" [templateLeft]="itemsPerPageTemplate" + [pt]="paginatorPt" currentPageReportTemplate="{first} - {last} of {totalRecords}">
- Items per page: + { expect(component.resetPageOnRowsChange).toBe(false); }); + it('should have role="navigation" on host element', () => { + expect(fixture.nativeElement.getAttribute('role')).toBe('navigation'); + }); + + it('should have aria-label="Pagination" by default', () => { + expect(fixture.nativeElement.getAttribute('aria-label')).toBe('Pagination'); + }); + + it('should reflect ariaLabel input on host element', () => { + fixture.componentRef.setInput('ariaLabel', 'Search results pagination'); + fixture.detectChanges(); + expect(fixture.nativeElement.getAttribute('aria-label')).toBe( + 'Search results pagination' + ); + }); + + it('should update aria-label when ariaLabel input changes', () => { + fixture.componentRef.setInput('ariaLabel', 'Search results pagination'); + fixture.detectChanges(); + expect(fixture.nativeElement.getAttribute('aria-label')).toBe( + 'Search results pagination' + ); + + fixture.componentRef.setInput('ariaLabel', ''); + fixture.detectChanges(); + expect(fixture.nativeElement.getAttribute('aria-label')).toBe('Pagination'); + }); + + it('should mark first button as aria-disabled when on first page', () => { + component.first = 0; + const pt = component.paginatorPt; + expect(pt.first['aria-disabled']).toBe('true'); + expect(pt.first.tabindex).toBe(-1); + }); + + it('should not mark first button as aria-disabled when not on first page', () => { + fixture.componentRef.setInput('first', 10); + fixture.detectChanges(); + const pt = component.paginatorPt; + expect(pt.first['aria-disabled']).toBeNull(); + expect(pt.first.tabindex).toBe(0); + }); + + it('should mark first button as aria-disabled when totalRecords is 0', () => { + component.first = 0; + fixture.componentRef.setInput('totalRecords', 0); + const pt = component.paginatorPt; + expect(pt.first['aria-disabled']).toBe('true'); + }); + it('should initialize row options from rowsPerPageOptions', () => { component.ngOnInit(); expect(component.rowOptions.length).toBe(3); @@ -51,6 +101,71 @@ describe('CpsPaginatorComponent', () => { expect(component.first).toBe(20); }); + describe('focus redirection when a boundary nav button becomes disabled', () => { + function getSelectedPageBtn() { + return fixture.nativeElement.querySelector( + '.p-paginator-page[aria-current="page"]' + ) as HTMLButtonElement | null; + } + + function mockActiveEl(selector: string) { + const btn = fixture.nativeElement.querySelector(selector); + jest + .spyOn(fixture.nativeElement.ownerDocument, 'activeElement', 'get') + .mockReturnValue(btn); + return btn; + } + + const firstPageEvent = { first: 0, rows: 10, page: 0, pageCount: 10 }; + const lastPageEvent = { first: 90, rows: 10, page: 9, pageCount: 10 }; + + it('should redirect focus when first-page button becomes disabled', async () => { + const navBtn = mockActiveEl('.p-paginator-first'); + const selectedPage = getSelectedPageBtn(); + expect(navBtn).not.toBeNull(); + expect(selectedPage).not.toBeNull(); + jest.spyOn(selectedPage!, 'focus'); + component.onPageChange(firstPageEvent); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(selectedPage!.focus).toHaveBeenCalled(); + }); + + it('should redirect focus when prev button lands on first page', async () => { + const navBtn = mockActiveEl('.p-paginator-prev'); + const selectedPage = getSelectedPageBtn(); + expect(navBtn).not.toBeNull(); + expect(selectedPage).not.toBeNull(); + jest.spyOn(selectedPage!, 'focus'); + component.onPageChange(firstPageEvent); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(selectedPage!.focus).toHaveBeenCalled(); + }); + + it('should redirect focus when last-page button becomes disabled', async () => { + const navBtn = mockActiveEl('.p-paginator-last'); + const selectedPage = getSelectedPageBtn(); + expect(navBtn).not.toBeNull(); + expect(selectedPage).not.toBeNull(); + jest.spyOn(component.paginator, 'isLastPage').mockReturnValue(true); + jest.spyOn(selectedPage!, 'focus'); + component.onPageChange(lastPageEvent); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(selectedPage!.focus).toHaveBeenCalled(); + }); + + it('should redirect focus when next button lands on last page', async () => { + const navBtn = mockActiveEl('.p-paginator-next'); + const selectedPage = getSelectedPageBtn(); + expect(navBtn).not.toBeNull(); + expect(selectedPage).not.toBeNull(); + jest.spyOn(component.paginator, 'isLastPage').mockReturnValue(true); + jest.spyOn(selectedPage!, 'focus'); + component.onPageChange(lastPageEvent); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(selectedPage!.focus).toHaveBeenCalled(); + }); + }); + it('should display paginator when there are multiple pages', () => { const paginator = fixture.nativeElement.querySelector('p-paginator'); expect(paginator).toBeTruthy(); @@ -93,6 +208,77 @@ describe('CpsPaginatorComponent', () => { expect(component.first).toBe(0); }); + describe('arrow key navigation', () => { + function dispatchArrow( + key: 'ArrowLeft' | 'ArrowRight', + target: HTMLElement + ) { + target.dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true }) + ); + } + + function getPageButton(index = 0): HTMLButtonElement { + return fixture.nativeElement.querySelectorAll('.p-paginator-page')[index]; + } + + it('should move focus to the next page button and click it on ArrowRight', () => { + fixture.detectChanges(); + const buttons = + fixture.nativeElement.querySelectorAll('.p-paginator-page'); + expect(buttons.length).toBeGreaterThanOrEqual(2); + const first = buttons[0] as HTMLButtonElement; + const second = buttons[1] as HTMLButtonElement; + jest.spyOn(second, 'click'); + jest.spyOn(second, 'focus'); + dispatchArrow('ArrowRight', first); + expect(second.focus).toHaveBeenCalled(); + expect(second.click).toHaveBeenCalled(); + }); + + it('should move focus to the previous page button and click it on ArrowLeft', () => { + fixture.detectChanges(); + const buttons = + fixture.nativeElement.querySelectorAll('.p-paginator-page'); + expect(buttons.length).toBeGreaterThanOrEqual(2); + const first = buttons[0] as HTMLButtonElement; + const second = buttons[1] as HTMLButtonElement; + jest.spyOn(first, 'click'); + jest.spyOn(first, 'focus'); + dispatchArrow('ArrowLeft', second); + expect(first.focus).toHaveBeenCalled(); + expect(first.click).toHaveBeenCalled(); + }); + + it('should do nothing on ArrowLeft when on the first visible page button and first page', () => { + jest.spyOn(component.pageChanged, 'emit'); + component.first = 0; + fixture.detectChanges(); + const btn = getPageButton(0); + expect(btn).not.toBeUndefined(); + dispatchArrow('ArrowLeft', btn); + expect(component.pageChanged.emit).not.toHaveBeenCalled(); + }); + + it('should ignore arrow keys on non-page-button elements', () => { + jest.spyOn(component.pageChanged, 'emit'); + const navBtn = fixture.nativeElement.querySelector('.p-paginator-first'); + expect(navBtn).not.toBeNull(); + dispatchArrow('ArrowRight', navBtn); + expect(component.pageChanged.emit).not.toHaveBeenCalled(); + }); + + it('should ignore arrow keys on non-button elements', () => { + jest.spyOn(component.pageChanged, 'emit'); + const span = + fixture.nativeElement.querySelector('span') ?? fixture.nativeElement; + span.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }) + ); + expect(component.pageChanged.emit).not.toHaveBeenCalled(); + }); + }); + it('should maintain current page when rows change if resetPageOnRowsChange is false', () => { component.resetPageOnRowsChange = false; component.first = 30; diff --git a/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.ts b/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.ts index 1d079a125..cdb75a149 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.ts @@ -1,17 +1,24 @@ import { Component, + ElementRef, EventEmitter, - Inject, + HostAttributeToken, + inject, Input, + OnChanges, OnInit, Output, - ViewChild + ViewChild, + type SimpleChanges } from '@angular/core'; import { DOCUMENT } from '@angular/common'; import { Paginator, PaginatorModule } from 'primeng/paginator'; import { CpsSelectComponent } from '../cps-select/cps-select.component'; import { getCSSColor } from '../../utils/colors-utils'; import { FormsModule } from '@angular/forms'; +import { isEqual } from 'lodash-es'; + +const DEFAULT_ROWS_PER_PAGE = [5, 10, 25, 50]; /** * CpsPaginatorComponent is a generic component to display content in paged format. @@ -21,9 +28,14 @@ import { FormsModule } from '@angular/forms'; selector: 'cps-paginator', imports: [PaginatorModule, CpsSelectComponent, FormsModule], templateUrl: './cps-paginator.component.html', - styleUrls: ['./cps-paginator.component.scss'] + styleUrls: ['./cps-paginator.component.scss'], + host: { + role: 'navigation', + '[attr.aria-label]': 'computedAriaLabel', + '(keydown)': 'onKeydown($event)' + } }) -export class CpsPaginatorComponent implements OnInit { +export class CpsPaginatorComponent implements OnInit, OnChanges { /** * Zero-relative number of the first row to be displayed. * @group Props @@ -66,6 +78,14 @@ export class CpsPaginatorComponent implements OnInit { */ @Input() resetPageOnRowsChange = false; + /** + * Accessible label for the paginator component. + * Falls back to "Pagination" when empty value is provided. + * @group Props + * @default Pagination + */ + @Input() ariaLabel = ''; + /** * Callback to invoke when page changes, the event object contains information about the new state. * @param {any} any - page changed. @@ -76,39 +96,150 @@ export class CpsPaginatorComponent implements OnInit { @ViewChild('paginator') paginator!: Paginator; + cvtBackgroundColor = ''; + computedAriaLabel = ''; + paginatorPt = { + first: { 'aria-disabled': null as string | null, tabindex: 0 } + }; + rowOptions: { label: string; value: number }[] = []; + private _currentRowsPerPageOptions: number[] = []; - // eslint-disable-next-line no-useless-constructor - constructor(@Inject(DOCUMENT) private document: Document) {} + private readonly _document = inject(DOCUMENT); + private readonly _elementRef = inject(ElementRef); + private readonly _staticAriaLabel: string | null = inject( + new HostAttributeToken('aria-label'), + { optional: true } + ); ngOnInit(): void { - this.backgroundColor = getCSSColor(this.backgroundColor, this.document); - if (this.rowsPerPageOptions.length < 1) - this.rowsPerPageOptions = [5, 10, 25, 50]; - - if (!this.rows) this.rows = this.rowsPerPageOptions[0]; - else { - if (!this.rowsPerPageOptions.includes(this.rows)) { - throw new Error('rowsPerPageOptions must include rows'); - } + this.cvtBackgroundColor = getCSSColor(this.backgroundColor, this._document); + this.paginatorPt = this._buildPaginatorPt(); + this._syncRows(); + this._updateAriaLabel(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.backgroundColor && !changes.backgroundColor.firstChange) { + this.cvtBackgroundColor = getCSSColor( + this.backgroundColor, + this._document + ); + } + if (changes.first || changes.totalRecords) { + this.paginatorPt = this._buildPaginatorPt(); + } + if ( + (changes.rows && !changes.rows.firstChange) || + (changes.rowsPerPageOptions && !changes.rowsPerPageOptions.firstChange) + ) { + this._syncRows(); + } + if (changes.ariaLabel && !changes.ariaLabel.firstChange) { + this._updateAriaLabel(); + } + } + + private _syncRows(): void { + const opts = + this.rowsPerPageOptions.length > 0 + ? this.rowsPerPageOptions + : DEFAULT_ROWS_PER_PAGE; + + if (this.rows && !opts.includes(this.rows)) { + throw new Error('rowsPerPageOptions must include rows'); } + this.rows = this.rows || opts[0]; - this.rowOptions = this.rowsPerPageOptions.map((v) => ({ - label: '' + v, - value: v - })); + if (!isEqual(opts, this._currentRowsPerPageOptions)) { + this._currentRowsPerPageOptions = opts; + this.rowOptions = opts.map((v) => ({ label: '' + v, value: v })); + } + } + + private _updateAriaLabel(): void { + this.computedAriaLabel = + this.ariaLabel?.trim() || this._staticAriaLabel?.trim() || 'Pagination'; + } + + private _buildPaginatorPt() { + const firstDisabled = this.first === 0 || this.totalRecords === 0; + return { + first: { + 'aria-disabled': firstDisabled ? 'true' : null, + tabindex: firstDisabled ? -1 : 0 + } + }; } onPageChange(event: any) { this.first = event.first; this.rows = event.rows; + this.paginatorPt = this._buildPaginatorPt(); this.pageChanged.emit(event); + + const activeEl = this._document.activeElement as HTMLElement | null; + const atFirst = this.paginator.isFirstPage(); + const atLast = this.paginator.isLastPage(); + if ( + (atFirst && + (activeEl?.classList.contains('p-paginator-first') || + activeEl?.classList.contains('p-paginator-prev'))) || + (atLast && + (activeEl?.classList.contains('p-paginator-last') || + activeEl?.classList.contains('p-paginator-next'))) + ) { + setTimeout(() => this._focusSelectedPageButton()); + } + } + + onKeydown(event: KeyboardEvent): void { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + + const target = event.target as HTMLElement; + if (!target.classList.contains('p-paginator-page')) return; + + event.preventDefault(); + + const pageButtons = this._getPageButtons(); + const currentIndex = pageButtons.indexOf(target as HTMLButtonElement); + const delta = event.key === 'ArrowRight' ? 1 : -1; + const targetIndex = currentIndex + delta; + + if (targetIndex >= 0 && targetIndex < pageButtons.length) { + pageButtons[targetIndex].focus(); + pageButtons[targetIndex].click(); + } else { + const focusedPageNum = this.paginator.pageLinks![currentIndex]; + const atBoundary = + delta > 0 + ? focusedPageNum >= this.paginator.getPageCount() + : focusedPageNum <= 1; + if (!atBoundary) { + this.paginator.changePage(focusedPageNum - 1 + delta); + setTimeout(() => this._focusSelectedPageButton()); + } + } + } + + private _getPageButtons(): HTMLButtonElement[] { + return Array.from( + this._elementRef.nativeElement.querySelectorAll('.p-paginator-page') + ) as HTMLButtonElement[]; + } + + private _focusSelectedPageButton(): void { + const selected = this._elementRef.nativeElement.querySelector( + '.p-paginator-page[aria-current="page"]' + ) as HTMLButtonElement | null; + selected?.focus(); } onRowsPerPageChange(rows: number) { if (this.resetPageOnRowsChange) { this.first = 0; this.paginator.first = 0; + this.paginatorPt = this._buildPaginatorPt(); } this.paginator.rows = rows; this.paginator.changePage(this.paginator.getPage()); diff --git a/projects/cps-ui-kit/src/lib/components/cps-paginator/pipes/cps-paginate.pipe.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-paginator/pipes/cps-paginate.pipe.spec.ts new file mode 100644 index 000000000..acbdd0761 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/components/cps-paginator/pipes/cps-paginate.pipe.spec.ts @@ -0,0 +1,80 @@ +import { CpsPaginatePipe } from './cps-paginate.pipe'; + +describe('CpsPaginatePipe', () => { + let pipe: CpsPaginatePipe; + + beforeEach(() => { + pipe = new CpsPaginatePipe(); + }); + + it('should create', () => { + expect(pipe).toBeTruthy(); + }); + + it('should return [] when items is null', () => { + expect(pipe.transform(null as any, { first: 0, rows: 5 })).toEqual([]); + }); + + it('should return [] when items is undefined', () => { + expect(pipe.transform(undefined as any, { first: 0, rows: 5 })).toEqual([]); + }); + + it('should return an empty array when items is empty', () => { + const result = pipe.transform([], { first: 0, rows: 5 }); + expect(result).toEqual([]); + }); + + it('should return the first page of items', () => { + const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + expect(pipe.transform(items, { first: 0, rows: 5 })).toEqual([ + 1, 2, 3, 4, 5 + ]); + }); + + it('should return the second page of items', () => { + const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + expect(pipe.transform(items, { first: 5, rows: 5 })).toEqual([ + 6, 7, 8, 9, 10 + ]); + }); + + it('should return a partial page when remaining items are fewer than rows', () => { + const items = [1, 2, 3, 4, 5, 6, 7]; + expect(pipe.transform(items, { first: 5, rows: 5 })).toEqual([6, 7]); + }); + + it('should default first to 0 when config.first is 0 (falsy)', () => { + const items = [1, 2, 3, 4, 5]; + expect(pipe.transform(items, { first: 0, rows: 3 })).toEqual([1, 2, 3]); + }); + + it('should default rows to 5 when config.rows is 0 (falsy)', () => { + const items = [1, 2, 3, 4, 5, 6, 7, 8]; + expect(pipe.transform(items, { first: 0, rows: 0 })).toEqual([ + 1, 2, 3, 4, 5 + ]); + }); + + it('should handle rows larger than the total number of items', () => { + const items = [1, 2, 3]; + expect(pipe.transform(items, { first: 0, rows: 10 })).toEqual([1, 2, 3]); + }); + + it('should return [] when first is beyond the end of the array', () => { + const items = [1, 2, 3, 4, 5]; + expect(pipe.transform(items, { first: 10, rows: 5 })).toEqual([]); + }); + + it('should work with rows of 1', () => { + const items = [10, 20, 30]; + expect(pipe.transform(items, { first: 1, rows: 1 })).toEqual([20]); + }); + + it('should work with object items', () => { + const items = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + expect(pipe.transform(items, { first: 2, rows: 2 })).toEqual([ + { id: 3 }, + { id: 4 } + ]); + }); +});