diff --git a/playwright/cps-accessibility.spec.ts b/playwright/cps-accessibility.spec.ts index b2b7ecfb..ea8db8ba 100644 --- a/playwright/cps-accessibility.spec.ts +++ b/playwright/cps-accessibility.spec.ts @@ -155,7 +155,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 34e3c2b9..0d66e719 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 c4246319..e520f567 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 @@ -2,24 +2,27 @@ #paginator (onPageChange)="onPageChange($event)" [first]="first" - [rows]="rows" - [style]="{ background: backgroundColor }" + [rows]="currentRows" + [style.background]="cvtBackgroundColor" [totalRecords]="totalRecords" [showFirstLastIcon]="true" [showCurrentPageReport]="true" [alwaysShow]="alwaysShow" [templateLeft]="itemsPerPageTemplate" + [pt]="paginatorPt" currentPageReportTemplate="{first} - {last} of {totalRecords}">
- Items per page: + diff --git a/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.scss b/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.scss index 352923ee..8397c73d 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.scss @@ -1,3 +1,5 @@ +@use '../../../../styles/mixins' as *; + $color-calm: var(--cps-color-calm); $text-color: var(--cps-color-text-dark); $border-color: var(--cps-color-text-dark); @@ -22,21 +24,22 @@ $elem-active-background: var(--cps-color-highlight-active); align-items: center; .cps-paginator-items-per-page-title { font-family: 'Source Sans Pro', sans-serif; - font-size: 14px; - margin-right: 12px; + font-size: 0.875rem; + margin-right: 0.75rem; cursor: default; } .cps-select-box { - min-height: 32px !important; + min-height: 2rem !important; background: transparent !important; .cps-select-box-items { - font-size: 14px !important; + font-size: 0.875rem !important; } .cps-select-box-chevron { + padding: 0.3125rem !important; .cps-icon { - width: 14px; - height: 14px; + width: 0.875rem; + height: 0.875rem; } } } @@ -51,7 +54,7 @@ $elem-active-background: var(--cps-color-highlight-active); margin: 0.143rem; padding: 0 0.5rem; font-family: 'Source Sans Pro', sans-serif; - font-size: 14px; + font-size: 0.875rem; height: unset; } @@ -76,17 +79,29 @@ $elem-active-background: var(--cps-color-highlight-active); cursor: default; } - .p-paginator .p-paginator-first:not(.p-disabled):not(.p-paginator-page-selected):hover, - .p-paginator .p-paginator-prev:not(.p-disabled):not(.p-paginator-page-selected):hover, - .p-paginator .p-paginator-next:not(.p-disabled):not(.p-paginator-page-selected):hover, - .p-paginator .p-paginator-last:not(.p-disabled):not(.p-paginator-page-selected):hover { + .p-paginator + .p-paginator-first:not(.p-disabled):not(.p-paginator-page-selected):hover, + .p-paginator + .p-paginator-prev:not(.p-disabled):not(.p-paginator-page-selected):hover, + .p-paginator + .p-paginator-next:not(.p-disabled):not(.p-paginator-page-selected):hover, + .p-paginator + .p-paginator-last:not(.p-disabled):not(.p-paginator-page-selected):hover { background: $elem-hover-background; border-color: unset; } - .p-paginator .p-paginator-first:not(.p-disabled):not(.p-paginator-page-selected):active, - .p-paginator .p-paginator-prev:not(.p-disabled):not(.p-paginator-page-selected):active, - .p-paginator .p-paginator-next:not(.p-disabled):not(.p-paginator-page-selected):active, - .p-paginator .p-paginator-last:not(.p-disabled):not(.p-paginator-page-selected):active { + .p-paginator + .p-paginator-first:not(.p-disabled):not( + .p-paginator-page-selected + ):active, + .p-paginator + .p-paginator-prev:not(.p-disabled):not(.p-paginator-page-selected):active, + .p-paginator + .p-paginator-next:not(.p-disabled):not(.p-paginator-page-selected):active, + .p-paginator + .p-paginator-last:not(.p-disabled):not( + .p-paginator-page-selected + ):active { background: $elem-active-background; } @@ -95,11 +110,11 @@ $elem-active-background: var(--cps-color-highlight-active); .p-paginator .p-paginator-next, .p-paginator .p-paginator-last { background-color: transparent; - border: 1px solid $border-color; - border-radius: 4px; + border: 0.0625rem solid $border-color; + border-radius: 0.25rem; color: $text-color; - min-width: 32px; - height: 32px; + min-width: 2rem; + height: 2rem; margin: 0.143rem; transition: box-shadow 0.2s; } @@ -108,48 +123,78 @@ $elem-active-background: var(--cps-color-highlight-active); display: inline-flex; } + .p-paginator-first-icon, + .p-paginator-prev-icon, + .p-paginator-next-icon, + .p-paginator-last-icon { + width: 0.875rem; + height: 0.875rem; + } + .p-disabled, .p-disabled * { cursor: default !important; pointer-events: none; } - .p-paginator .p-paginator-pages .p-paginator-page.p-paginator-page-selected { + .p-paginator + .p-paginator-pages + .p-paginator-page.p-paginator-page-selected { background: $color-calm; border-color: $color-calm; color: white; } - .p-paginator .p-paginator-pages .p-paginator-page:not(.p-paginator-page-selected):hover { + .p-paginator + .p-paginator-pages + .p-paginator-page:not(.p-paginator-page-selected):hover { background: $elem-hover-background; border-color: unset; } - .p-paginator .p-paginator-pages .p-paginator-page:not(.p-paginator-page-selected):active { + .p-paginator + .p-paginator-pages + .p-paginator-page:not(.p-paginator-page-selected):active { background: $elem-active-background; } .p-paginator .p-paginator-pages .p-paginator-page { background-color: transparent; - border: 1px solid $border-color; - border-radius: 4px; + border: 0.0625rem solid $border-color; + border-radius: 0.25rem; color: $text-color; - min-width: 32px; - height: 32px; + min-width: 2rem; + height: 2rem; margin: 0.143rem; transition: box-shadow 0.2s; } - .p-paginator-element:focus { - z-index: 1; - position: relative; - } - + .p-paginator-first:focus, + .p-paginator-prev:focus, + .p-paginator-next:focus, + .p-paginator-last:focus, .p-paginator-page:focus { + z-index: 1; outline: 0 none; - outline-offset: 0; box-shadow: unset; } + .p-paginator-first:not(.p-disabled):focus-visible, + .p-paginator-prev:not(.p-disabled):focus-visible, + .p-paginator-next:not(.p-disabled):focus-visible, + .p-paginator-last:not(.p-disabled):focus-visible, + .p-paginator-page:focus-visible { + overflow: visible; + @include focus-ring(0.1875rem, 0.25rem, 0.25rem); + } + + .p-paginator-first:not(.p-disabled):focus-visible, + .p-paginator-prev:not(.p-disabled):focus-visible, + .p-paginator-next:not(.p-disabled):focus-visible, + .p-paginator-last:not(.p-disabled):focus-visible, + .p-paginator-page:not(.p-paginator-page-selected):focus-visible { + background: $elem-hover-background; + } + .p-paginator-page { text-align: left; background-color: transparent; @@ -159,7 +204,7 @@ $elem-active-background: var(--cps-color-highlight-active); cursor: pointer; -webkit-user-select: none; user-select: none; - font-size: 14px; + font-size: 0.875rem; font-family: 'Source Sans Pro', sans-serif; } @@ -172,6 +217,6 @@ $elem-active-background: var(--cps-color-highlight-active); ::ng-deep .cps-select-options-menu.cps-paginator-page-options { .cps-select-options-option { - font-size: 14px; + font-size: 0.875rem; } } diff --git a/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.spec.ts index 53b6174e..9ba9d6f8 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-paginator/cps-paginator.component.spec.ts @@ -30,6 +30,43 @@ describe('CpsPaginatorComponent', () => { 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 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', () => { + component.first = 10; + 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 +88,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', (done) => { + const selectedPage = getSelectedPageBtn(); + if (!mockActiveEl('.p-paginator-first') || !selectedPage) return done(); + jest.spyOn(selectedPage, 'focus'); + component.onPageChange(firstPageEvent); + setTimeout(() => { + expect(selectedPage.focus).toHaveBeenCalled(); + done(); + }, 0); + }); + + it('should redirect focus when prev button lands on first page', (done) => { + const selectedPage = getSelectedPageBtn(); + if (!mockActiveEl('.p-paginator-prev') || !selectedPage) return done(); + jest.spyOn(selectedPage, 'focus'); + component.onPageChange(firstPageEvent); + setTimeout(() => { + expect(selectedPage.focus).toHaveBeenCalled(); + done(); + }, 0); + }); + + it('should redirect focus when last-page button becomes disabled', (done) => { + const selectedPage = getSelectedPageBtn(); + if (!mockActiveEl('.p-paginator-last') || !selectedPage) return done(); + jest.spyOn(component.paginator, 'isLastPage').mockReturnValue(true); + jest.spyOn(selectedPage, 'focus'); + component.onPageChange(lastPageEvent); + setTimeout(() => { + expect(selectedPage.focus).toHaveBeenCalled(); + done(); + }, 0); + }); + + it('should redirect focus when next button lands on last page', (done) => { + const selectedPage = getSelectedPageBtn(); + if (!mockActiveEl('.p-paginator-next') || !selectedPage) return done(); + jest.spyOn(component.paginator, 'isLastPage').mockReturnValue(true); + jest.spyOn(selectedPage, 'focus'); + component.onPageChange(lastPageEvent); + setTimeout(() => { + expect(selectedPage.focus).toHaveBeenCalled(); + done(); + }, 0); + }); + }); + it('should display paginator when there are multiple pages', () => { const paginator = fixture.nativeElement.querySelector('p-paginator'); expect(paginator).toBeTruthy(); @@ -93,6 +195,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'); + if (buttons.length < 2) return; + 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'); + if (buttons.length < 2) return; + 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); + if (!btn) return; + 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'); + if (!navBtn) return; + 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 1d079a12..84cbab58 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,26 @@ import { + AfterViewInit, Component, + ElementRef, EventEmitter, - Inject, + HostAttributeToken, + inject, Input, + OnChanges, OnInit, Output, - ViewChild + Renderer2, + 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 +30,13 @@ 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', + '(keydown)': 'onKeydown($event)' + } }) -export class CpsPaginatorComponent implements OnInit { +export class CpsPaginatorComponent implements OnInit, OnChanges, AfterViewInit { /** * Zero-relative number of the first row to be displayed. * @group Props @@ -66,6 +79,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,33 +97,152 @@ export class CpsPaginatorComponent implements OnInit { @ViewChild('paginator') paginator!: Paginator; + cvtBackgroundColor = ''; + rowOptions: { label: string; value: number }[] = []; + currentRows = 0; + 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 _renderer = inject(Renderer2); + 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'); + get paginatorPt() { + const firstDisabled = this.first === 0 || this.totalRecords === 0; + return { + first: { + 'aria-disabled': firstDisabled ? 'true' : null, + tabindex: firstDisabled ? -1 : 0 } + }; + } + + ngOnInit(): void { + this.cvtBackgroundColor = getCSSColor(this.backgroundColor, this._document); + this._syncRows(); + this._applyAriaLabel(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.backgroundColor && !changes.backgroundColor.firstChange) { + this.cvtBackgroundColor = getCSSColor( + this.backgroundColor, + this._document + ); + } + if ( + (changes.rows && !changes.rows.firstChange) || + (changes.rowsPerPageOptions && !changes.rowsPerPageOptions.firstChange) + ) { + this._syncRows(); + } + if (changes.ariaLabel && !changes.ariaLabel.firstChange) { + this._applyAriaLabel(); + } + } + + ngAfterViewInit(): void { + if (!this._elementRef.nativeElement.getAttribute('aria-label')) { + this._renderer.setAttribute( + this._elementRef.nativeElement, + 'aria-label', + 'Pagination' + ); + } + } + + 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.currentRows = this.rows || opts[0]; + + if (!isEqual(opts, this._currentRowsPerPageOptions)) { + this._currentRowsPerPageOptions = opts; + this.rowOptions = opts.map((v) => ({ label: '' + v, value: v })); + } + } - this.rowOptions = this.rowsPerPageOptions.map((v) => ({ - label: '' + v, - value: v - })); + private _applyAriaLabel(): void { + const label = this.ariaLabel || this._staticAriaLabel; + if (label) { + this._renderer.setAttribute( + this._elementRef.nativeElement, + 'aria-label', + label + ); + } } onPageChange(event: any) { this.first = event.first; - this.rows = event.rows; + this.currentRows = event.rows; 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) { 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 00000000..b89f622a --- /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 the same 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 } + ]); + }); +});