diff --git a/projects/angular-ui/src/lib/bao.module.ts b/projects/angular-ui/src/lib/bao.module.ts index 728a984..d7fa582 100644 --- a/projects/angular-ui/src/lib/bao.module.ts +++ b/projects/angular-ui/src/lib/bao.module.ts @@ -19,6 +19,7 @@ import { BaoIconModule } from './icon'; import { BaoListModule } from './list'; import { BaoMessageBarModule } from './message-bar'; import { BaoModalModule } from './modal/module'; +import { BaoPaginationModule } from './pagination'; import { BaoRadioModule } from './radio'; import { BaoSnackBarModule } from './snack-bar/module'; import { BaoSummaryModule } from './summary'; @@ -56,8 +57,8 @@ import { BaoTagModule } from './tag'; BaoFileModule, BaoSnackBarModule, BaoSystemHeaderModule, - BaoMessageBarModule - // TODO: reactivate once component does not depend on global css BaoBadgeModule, + BaoMessageBarModule, + BaoPaginationModule ] }) export class BaoModule {} diff --git a/projects/angular-ui/src/lib/pagination/index.ts b/projects/angular-ui/src/lib/pagination/index.ts new file mode 100644 index 0000000..122aee0 --- /dev/null +++ b/projects/angular-ui/src/lib/pagination/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright (c) 2025 Ville de Montreal. All rights reserved. + * Licensed under the MIT license. + * See LICENSE file in the project root for full license information. + */ +export * from './module'; +export * from './pagination.component'; diff --git a/projects/angular-ui/src/lib/pagination/module.ts b/projects/angular-ui/src/lib/pagination/module.ts new file mode 100644 index 0000000..3ea5ec3 --- /dev/null +++ b/projects/angular-ui/src/lib/pagination/module.ts @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Ville de Montreal. All rights reserved. + * Licensed under the MIT license. + * See LICENSE file in the project root for full license information. + */ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { BaoIconModule } from '../icon'; +import { BaoPaginationComponent } from './pagination.component'; + +@NgModule({ + imports: [CommonModule, BaoIconModule, FormsModule], + declarations: [BaoPaginationComponent], + exports: [BaoPaginationComponent] +}) +export class BaoPaginationModule {} diff --git a/projects/angular-ui/src/lib/pagination/pagination.component.html b/projects/angular-ui/src/lib/pagination/pagination.component.html new file mode 100644 index 0000000..f00b985 --- /dev/null +++ b/projects/angular-ui/src/lib/pagination/pagination.component.html @@ -0,0 +1,77 @@ +
+ + {{ rangeLabel }} + +
+ + +
+
+ diff --git a/projects/angular-ui/src/lib/pagination/pagination.component.scss b/projects/angular-ui/src/lib/pagination/pagination.component.scss new file mode 100644 index 0000000..d4ba0d7 --- /dev/null +++ b/projects/angular-ui/src/lib/pagination/pagination.component.scss @@ -0,0 +1,93 @@ +@import '../core/typography'; +@import '../core/colors'; +@import '../core//grid'; + +.bao-pagination { + display: flex; + flex-direction: column; + justify-content: center; + @include respond-to(md) { + justify-content: flex-start; + } + > .items-diplayed-controls { + display: flex; + flex-direction: column; + justify-content: center; + align-self: center; + @include respond-to(md) { + flex-direction: row; + justify-content: space-between; + align-self: auto; + margin-bottom: 1rem; + } + .range-display { + @include typo-interface-small-bold; + margin-bottom: 1rem; + @include respond-to(md) { + margin-bottom: 0rem; + } + } + > .items-per-page { + display: flex; + align-items: baseline; + margin-bottom: 2rem; + @include respond-to(md) { + margin-bottom: 0rem; + } + > .select-label { + @include typo-interface-small-bold; + color: $neutral-secondary; + margin-right: 1rem; + } + > .dropdown-select { + background-color: $neutral-ground; + border-radius: 0.25rem; + border-color: $neutral-stroke; + border-style: solid; + border-width: thin; + padding: 0.4375rem 2.4375rem 0.4375rem 0.4375rem; + @include typo-interface-medium-normal; + color: $ground-reversed; + appearance: none; + background: $white + url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3e%3cpath fill='%23adb2bd' d='M12 12.586l3.293-3.293a1 1 0 011.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L12 12.586z'/%3e%3c/svg%3e") + right 0.4375rem center/24px 24px no-repeat; + } + } + } + .page-controls { + align-self: center; + > ul { + display: flex; + padding-left: 0; + margin-bottom: 0; + > .page-item { + &.disabled > .page-link > .bao-icon { + color: $neutral-tertiary; + } + > .page-link { + border-style: none; + } + &.active { + > .page-link { + background-color: $highlight-light; + border: 1px solid $action; + border-radius: 0.25rem; + } + } + > .page-link { + font-size: 0.875rem; + &:focus { + box-shadow: inset 0px 0px 0px 2px $informative-reversed; + } + } + > .ellipsis { + font-weight: bold; + &:hover { + background-color: $neutral-ground; + } + } + } + } + } +} diff --git a/projects/angular-ui/src/lib/pagination/pagination.component.spec.ts b/projects/angular-ui/src/lib/pagination/pagination.component.spec.ts new file mode 100644 index 0000000..2277f28 --- /dev/null +++ b/projects/angular-ui/src/lib/pagination/pagination.component.spec.ts @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2025 Ville de Montreal. All rights reserved. + * Licensed under the MIT license. + * See LICENSE file in the project root for full license information. + */ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BaoPaginationComponent } from './pagination.component'; + +describe('PaginationComponent', () => { + let component: BaoPaginationComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [BaoPaginationComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(BaoPaginationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('Paging tests', () => { + const updateComponentWithPaging = (paging, val = 0) => { + component.totalPages = Math.ceil(paging.totalCount / paging.limit); // total number of pages based on number of items per page + component.currentPage = + (val ? val : Math.floor(paging.offset / paging.limit)) + 1; // passed value or page value based on offset + }; + + it('Should correctly navigate to target page based on paging input values', done => { + const paging = { limit: 20, offset: 0, totalCount: 100 }; + updateComponentWithPaging(paging); + + component.pageChanged.subscribe(val => { + updateComponentWithPaging(paging, val); + + expect(component.totalPages).toEqual(5); + expect(component.currentPage).toEqual(targetPage); + done(); + }); + expect(component.totalPages).toEqual(5); + expect(component.currentPage).toEqual(1); + + // simulate page change + const targetPage = 3; + paging.offset = 40; + component.goTo(targetPage); + }); + }); + + describe('Page number tests', () => { + it('should call goTo when a page number is clicked', () => { + component.displayedPages = [1, 2, 3]; + fixture.detectChanges(); + spyOn(component, 'goTo'); + + const pageNumberButton = fixture.debugElement.query( + By.css('.page-item:nth-child(2) .page-link') // since there is no elipsis, number page-items start at index 2 + ).nativeElement; + pageNumberButton.click(); + + expect(component.goTo).toHaveBeenCalledWith(1); + }); + + it('should mark the selected page number as active', () => { + component.displayedPages = [1, 2, 3]; + component.currentPage = 1; + fixture.detectChanges(); + + const pageNumberButton = fixture.debugElement.query( + By.css('.page-item.active .page-link') + ).nativeElement; + + expect(pageNumberButton.textContent).toContain('1'); + }); + }); + + describe('Previous button tests', () => { + it('should disable the Previous button when on the first page', () => { + component.currentPage = 1; + fixture.detectChanges(); + + const previousButton = fixture.debugElement.query( + By.css('.page-item:first-child') + ).nativeElement; + + expect(previousButton.classList).toContain('disabled'); + }); + + it('should enable the Previous button when not on the first page', () => { + component.currentPage = 2; + fixture.detectChanges(); + + const previousButton = fixture.debugElement.query( + By.css('.page-item:first-child') + ).nativeElement; + + expect(previousButton.classList).not.toContain('disabled'); + }); + + it('should call handlePreviousClick when Previous button is clicked', () => { + spyOn(component, 'handlePreviousClick'); + + const previousButtonLink = fixture.debugElement.query( + By.css('.page-item:first-child .page-link') + ).nativeElement; + previousButtonLink.click(); + + expect(component.handlePreviousClick).toHaveBeenCalled(); + }); + }); + + describe('Next button tests', () => { + it('should disable the Next button when on the last page', () => { + component.currentPage = 5; + component.totalPages = 5; + fixture.detectChanges(); + + const nextButton = fixture.debugElement.query( + By.css('.page-item:last-child') + ).nativeElement; + + expect(nextButton.classList).toContain('disabled'); + }); + + it('should enable the Next button when not on the last page', () => { + component.currentPage = 4; + component.totalPages = 5; + fixture.detectChanges(); + + const nextButton = fixture.debugElement.query( + By.css('.page-item:last-child') + ).nativeElement; + expect(nextButton.classList).not.toContain('disabled'); + }); + + it('should call handleNextClick when Next button is clicked', () => { + spyOn(component, 'handleNextClick'); + + const nextButtonLink = fixture.debugElement.query( + By.css('.page-item:last-child .page-link') + ).nativeElement; + nextButtonLink.click(); + + expect(component.handleNextClick).toHaveBeenCalled(); + }); + }); + + describe('First Elipsis', () => { + it('should display the first ellipsis when total pages is greater than 5 and current page is last page', () => { + component.totalItems = 75; + component.itemsPerPage = 10; + component.currentPage = 8; + component.ngOnInit(); + fixture.detectChanges(); + + const firstEllipsis = fixture.debugElement.query( + By.css('.page-item:nth-child(3) .page-link') + ).nativeElement; + + expect(firstEllipsis.textContent).toContain('...'); + }); + + it('should not display the first ellipsis when total pages is greater than 5 and current page is 1', () => { + component.totalItems = 75; + component.itemsPerPage = 10; + component.currentPage = 1; + component.ngOnInit(); + fixture.detectChanges(); + + const firstEllipsis = fixture.debugElement.query( + By.css('.page-item:nth-child(3) .page-link') + ).nativeElement; + + expect(firstEllipsis.textContent).not.toContain('...'); + }); + }); + + describe('Second Elipsis', () => { + it('should display the second ellipsis when total pages is greater than 5 and current page is 1', () => { + component.totalItems = 75; + component.itemsPerPage = 10; + component.currentPage = 1; + component.ngOnInit(); + fixture.detectChanges(); + + const lastEllipsis = fixture.debugElement.query( + By.css('.page-item:nth-last-child(3) .page-link') + ).nativeElement; + + expect(lastEllipsis.textContent).toContain('...'); + }); + + it('should not display the second ellipsis when total pages is greater than 5 and current page is last page', () => { + component.totalItems = 75; + component.itemsPerPage = 10; + component.currentPage = 8; + component.ngOnInit(); + fixture.detectChanges(); + + const lastEllipsis = fixture.debugElement.query( + By.css('.page-item:nth-last-child(3) .page-link') + ).nativeElement; + + expect(lastEllipsis.textContent).not.toContain('...'); + }); + }); +}); diff --git a/projects/angular-ui/src/lib/pagination/pagination.component.ts b/projects/angular-ui/src/lib/pagination/pagination.component.ts new file mode 100644 index 0000000..46f891d --- /dev/null +++ b/projects/angular-ui/src/lib/pagination/pagination.component.ts @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2025 Ville de Montreal. All rights reserved. + * Licensed under the MIT license. + * See LICENSE file in the project root for full license information. + */ +import { + ChangeDetectorRef, + Component, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, + ViewEncapsulation +} from '@angular/core'; + +@Component({ + selector: 'bao-pagination', + templateUrl: './pagination.component.html', + styleUrls: ['./pagination.component.scss'], + encapsulation: ViewEncapsulation.None, + host: { + class: 'bao-pagination' + } +}) +export class BaoPaginationComponent implements OnChanges, OnInit { + /** + * The total number of items. + */ + @Input() + public totalItems: number; + /** + * The number of items per page as selected. + */ + @Input() + public itemsPerPage: number = 10; + /** + * The current page number. + */ + @Input() + public currentPage: number = 1; + /** + * The different page size options. + */ + @Input() + public pageSizeOptions: number[] = [10, 25, 50, 100]; + /** + * The label for the type of items that are displayed on the page + */ + @Input() + public itemLabel: string = 'items'; + /** + * If selector for number of items per page should be displayed or not + */ + @Input() + public showItemsPerPageSelector: boolean = true; + /** + * EventEmitter that triggers when there is a page change and emits page number (index adjusted) + */ + @Output() + public pageChanged = new EventEmitter(); + /** + * EventEmitter that triggers when the number of items per page is changed. + */ + @Output() + public itemsPerPageChanged = new EventEmitter(); + /** + * Page number list to display + */ + public displayedPages: number[]; + /** + * Max number of pages to display + */ + private _maxPages: number = 5; + /** + * Number of pages in total. + */ + private _totalPages: number; + /** + * Position of first item being displayed on current page. + */ + private _startItem: number; + /** + * Position of last item being displayed on current page. + */ + private _endItem: number; + + constructor(private cdr: ChangeDetectorRef) { + this.displayedPages = []; + } + + public get totalPages(): number { + return this._totalPages; + } + + public set totalPages(value: number) { + this._totalPages = value; + } + + public get startItem(): number { + return this._startItem; + } + + public set startItem(value: number) { + this._startItem = value; + } + + public get endItem(): number { + return this._endItem; + } + + public set endItem(value: number) { + this._endItem = value; + } + + public get rangeLabel(): string { + return `${this.startItem} à ${this.endItem} sur ${this.totalItems} ${this.itemLabel}`; + } + + /** + * Flag that indicates if a previous page exists for the current list + */ + public get hasPrevious(): boolean { + return this.currentPage !== 1; + } + /** + * Flag that indicates if a next page exists for the current list + */ + public get hasNext(): boolean { + return this.currentPage < this.totalPages; + } + + public ngOnInit(): void { + this.startItem = this.updateStartItem(); + this.endItem = this.updateEndItem(); + this.totalPages = this.updateTotalPages(); + this.displayedPages = this.buildPageNumbers(); + this.cdr.detectChanges(); + } + + /** + * Update list of displayed pages when current page is changed. + */ + public ngOnChanges(changes: SimpleChanges): void { + if (changes.hasOwnProperty('currentPage')) { + this.displayedPages = this.buildPageNumbers(); + } + } + /** + * Navigate to specific page + */ + public goTo(page: number): void { + this.currentPage = page; + this.startItem = this.updateStartItem(); + this.endItem = this.updateEndItem(); + this.displayedPages = this.buildPageNumbers(); + this.pageChanged.emit(page - 1); + } + /** + * Navigate to previous page + */ + public handlePreviousClick() { + if (this.hasPrevious) { + this.goTo(this.currentPage - 1); + } + } + /** + * Navigate to next page + */ + public handleNextClick(): void { + if (this.hasNext) { + this.goTo(this.currentPage + 1); + } + } + + /** + * Update all required variables whenever the number of items displayed per page is changed. + * @param value New amount of items per page + */ + public handlePageSizeChange(value: number) { + this.currentPage = + this.currentPage > this.totalPages ? this.totalPages : this.currentPage; + this.startItem = this.updateStartItem(); + this.endItem = this.updateEndItem(); + this.totalPages = this.updateTotalPages(); + this.displayedPages = this.buildPageNumbers(); + this.itemsPerPageChanged.emit(value); + } + + private updateTotalPages(): number { + return Math.ceil(this.totalItems / this.itemsPerPage); + } + + private updateStartItem(): number { + return Math.min( + (this.currentPage - 1) * this.itemsPerPage + 1, + Math.floor(this.totalItems / 10) * 10 + ); + } + + private updateEndItem(): number { + return Math.min( + this.startItem + Number(this.itemsPerPage) - 1, + this.totalItems + ); + } + + /** + * Generate series of page numbers to display. The list always starts with page 1 + * and ends with last page. + * Adds negative numbers for ellipsis. + */ + private buildPageNumbers(): number[] { + const pages = []; + // If total pages are 5 or less, include all pages + if (this.totalPages <= this._maxPages) { + for (let i = 1; i <= this.totalPages; i++) { + pages.push(i); + } + } else { + pages.push(1); + // Determine middle pages + let middlePages = []; + if (this.currentPage <= 3) { + middlePages = [2, 3, 4, -1]; + } else if (this.currentPage >= this.totalPages - 2) { + middlePages = [ + -1, + this.totalPages - 3, + this.totalPages - 2, + this.totalPages - 1 + ]; + } else { + middlePages = [ + -1, + this.currentPage - 1, + this.currentPage, + this.currentPage + 1, + -1 + ]; + } + pages.push(...middlePages); + pages.push(this.totalPages); + } + return pages; + } +} diff --git a/projects/angular-ui/src/public-api.ts b/projects/angular-ui/src/public-api.ts index 1f3b6b7..c7490d6 100644 --- a/projects/angular-ui/src/public-api.ts +++ b/projects/angular-ui/src/public-api.ts @@ -3,27 +3,28 @@ * Licensed under the MIT license. * See LICENSE file in the project root for full license information. */ -export * from './lib/bao.module'; -export * from './lib/button/index'; -export * from './lib/icon/index'; export * from './lib/alert/index'; +export * from './lib/avatar'; +export * from './lib/badge/index'; +export * from './lib/bao.module'; export * from './lib/breadcrumb/index'; +export * from './lib/button/index'; export * from './lib/card/index'; -export * from './lib/badge/index'; -export * from './lib/tag/index'; -export * from './lib/header-info/index'; -export * from './lib/list/index'; export * from './lib/checkbox/index'; -export * from './lib/radio/index'; export * from './lib/common-components/index'; -export * from './lib/summary/index'; -export * from './lib/shared'; -export * from './lib/avatar'; -export * from './lib/tabs'; -export * from './lib/modal'; -export * from './lib/hyperlink'; export * from './lib/dropdown-menu'; export * from './lib/file'; +export * from './lib/header-info/index'; +export * from './lib/hyperlink'; +export * from './lib/icon/index'; +export * from './lib/list/index'; +export * from './lib/message-bar'; +export * from './lib/modal'; +export * from './lib/pagination/index'; +export * from './lib/radio/index'; +export * from './lib/shared'; export * from './lib/snack-bar'; +export * from './lib/summary/index'; export * from './lib/system-header'; -export * from './lib/message-bar'; +export * from './lib/tabs'; +export * from './lib/tag/index'; diff --git a/projects/angular-ui/tsconfig.lib.json b/projects/angular-ui/tsconfig.lib.json index 305b0b9..2a5306d 100644 --- a/projects/angular-ui/tsconfig.lib.json +++ b/projects/angular-ui/tsconfig.lib.json @@ -8,5 +8,8 @@ "inlineSources": true, "types": [] }, + "angularCompilerOptions": { + "compilationMode": "partial" + }, "exclude": ["src/test.ts", "**/*.spec.ts"] } diff --git a/projects/storybook-angular/src/stories/Pagination/Pagination.stories.ts b/projects/storybook-angular/src/stories/Pagination/Pagination.stories.ts new file mode 100644 index 0000000..8f9ce88 --- /dev/null +++ b/projects/storybook-angular/src/stories/Pagination/Pagination.stories.ts @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Ville de Montreal. All rights reserved. + * Licensed under the MIT license. + * See LICENSE file in the project root for full license information. + */ +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Meta, moduleMetadata, StoryFn } from '@storybook/angular'; +import { BaoIconModule } from 'projects/angular-ui/src/lib/icon/module'; +import { BaoPaginationModule } from 'projects/angular-ui/src/lib/pagination/module'; +import { BaoPaginationComponent } from 'projects/angular-ui/src/lib/pagination/pagination.component'; + +const description = ` +Pagination is used to navigate through a long list of items +## Documentation +The full documentation of this component is available in the Hochelaga design system documentation under "[Pagination](https://zeroheight.com/575tugn0n/p/65fd94)". +`; + +export default { + title: 'Components/Pagination', + decorators: [ + moduleMetadata({ + imports: [CommonModule, BaoIconModule, FormsModule, BaoPaginationModule] + }) + ], + component: BaoPaginationComponent, + parameters: { + docs: { + description: { + component: description + } + } + }, + argTypes: { + ngOnChanges: { table: { disable: true } }, + ngOnInit: { table: { disable: true } }, + _maxPages: { table: { disable: true } }, + _startItem: { table: { disable: true } }, + _endItem: { table: { disable: true } }, + _totalPages: { table: { disable: true } }, + displayedPages: { table: { disable: true } }, + buildPageNumbers: { table: { disable: true } }, + goTo: { table: { disable: true } }, + handleNextClick: { table: { disable: true } }, + handlePreviousClick: { table: { disable: true } }, + handlePageSizeChange: { table: { disable: true } }, + updateStartItem: { table: { disable: true } }, + updateEndItem: { table: { disable: true } }, + updateTotalPages: { table: { disable: true } } + } +} as Meta; + +const Template: StoryFn = ( + args: BaoPaginationComponent +) => ({ + props: args +}); + +export const Default = Template.bind({}); +Default.args = { + totalItems: 65, + itemsPerPage: 10, + currentPage: 4, + pageSizeOptions: [10, 25, 50, 100], + itemLabel: 'documents', + showItemsPerPageSelector: true +}; + +export const NoItemsPerPageSelector = Template.bind({}); +NoItemsPerPageSelector.storyName = 'No items per page selector'; +NoItemsPerPageSelector.args = { + totalItems: 34, + itemsPerPage: 10, + currentPage: 4, + pageSizeOptions: [10, 25, 50, 100], + itemLabel: 'documents', + showItemsPerPageSelector: false +};