From 85fb00a1513f484e2cefb0af2ff184f5d0fb58be Mon Sep 17 00:00:00 2001 From: Andrew Seguin Date: Fri, 23 Jun 2017 11:51:39 -0700 Subject: [PATCH] feat(pagination): initial pagination component (#5156) --- package-lock.json | 8 +- src/demo-app/data-table/data-table-demo.html | 7 + src/demo-app/data-table/data-table-demo.ts | 11 +- src/demo-app/data-table/person-data-source.ts | 60 ++-- src/demo-app/demo-app-module.ts | 2 + src/lib/core/theming/_all-theme.scss | 2 + src/lib/core/typography/_all-typography.scss | 2 + src/lib/module.ts | 2 + src/lib/paginator/_paginator-theme.scss | 42 +++ src/lib/paginator/index.ts | 35 +++ src/lib/paginator/paginator-intl.ts | 41 +++ src/lib/paginator/paginator.html | 40 +++ src/lib/paginator/paginator.md | 26 ++ src/lib/paginator/paginator.scss | 81 +++++ src/lib/paginator/paginator.spec.ts | 279 ++++++++++++++++++ src/lib/paginator/paginator.ts | 157 ++++++++++ src/lib/public_api.ts | 1 + src/material-examples/example-module.ts | 31 +- .../paginator-configurable-example.css | 1 + .../paginator-configurable-example.html | 28 ++ .../paginator-configurable-example.ts | 20 ++ .../paginator-overview-example.css | 1 + .../paginator-overview-example.html | 4 + .../paginator-overview-example.ts | 8 + tools/dgeni/index.js | 1 + 25 files changed, 854 insertions(+), 36 deletions(-) create mode 100644 src/lib/paginator/_paginator-theme.scss create mode 100644 src/lib/paginator/index.ts create mode 100644 src/lib/paginator/paginator-intl.ts create mode 100644 src/lib/paginator/paginator.html create mode 100644 src/lib/paginator/paginator.md create mode 100644 src/lib/paginator/paginator.scss create mode 100644 src/lib/paginator/paginator.spec.ts create mode 100644 src/lib/paginator/paginator.ts create mode 100644 src/material-examples/paginator-configurable/paginator-configurable-example.css create mode 100644 src/material-examples/paginator-configurable/paginator-configurable-example.html create mode 100644 src/material-examples/paginator-configurable/paginator-configurable-example.ts create mode 100644 src/material-examples/paginator-overview/paginator-overview-example.css create mode 100644 src/material-examples/paginator-overview/paginator-overview-example.html create mode 100644 src/material-examples/paginator-overview/paginator-overview-example.ts diff --git a/package-lock.json b/package-lock.json index 26786d5e0849..39974e88d50d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4169,7 +4169,7 @@ "glob": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", - "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "integrity": "sha1-wZyd+aAocC1nhhI4SmVSQExjbRU=", "dev": true }, "glob-base": { @@ -8070,7 +8070,7 @@ "npmlog": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.0.tgz", - "integrity": "sha512-ocolIkZYZt8UveuiDS0yAkkIjid1o7lPG8cYm05yNYzBn8ykQtaiPMEGp8fY9tKdDgm8okpdKzkvu1y9hUYugA==", + "integrity": "sha1-3Fm+6F9k8A7UJO+yrweD3yXRwLU=", "dev": true }, "null-check": { @@ -11254,7 +11254,7 @@ "wide-align": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.2.tgz", - "integrity": "sha512-ijDLlyQ7s6x1JgCLur53osjm/UXUYD9+0PbYKrBsYisYXzCxN+HC3mYDNy/dWdmf3AwqwU3CXwDCvsNgGK1S0w==", + "integrity": "sha1-Vx4PGwYEY268DfwhsDObvjE0FxA=", "dev": true }, "widest-line": { @@ -11316,7 +11316,7 @@ "write-file-atomic": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.1.0.tgz", - "integrity": "sha512-0TZ20a+xcIl4u0+Mj5xDH2yOWdmQiXlKf9Hm+TgDXjTMsEYb+gDrmb8e8UNAzMCitX8NBqG4Z/FUQIyzv/R1JQ==", + "integrity": "sha1-F2n0tVHu3OQZ8FBd6uLiZ2NULTc=", "dev": true }, "write-file-stdout": { diff --git a/src/demo-app/data-table/data-table-demo.html b/src/demo-app/data-table/data-table-demo.html index 859c8d03b538..728a2ae7b597 100644 --- a/src/demo-app/data-table/data-table-demo.html +++ b/src/demo-app/data-table/data-table-demo.html @@ -78,4 +78,11 @@ }"> + + + diff --git a/src/demo-app/data-table/data-table-demo.ts b/src/demo-app/data-table/data-table-demo.ts index 63732a2833cd..af13af2199a0 100644 --- a/src/demo-app/data-table/data-table-demo.ts +++ b/src/demo-app/data-table/data-table-demo.ts @@ -1,6 +1,7 @@ -import {Component} from '@angular/core'; +import {Component, ViewChild} from '@angular/core'; import {PeopleDatabase, UserData} from './people-database'; import {PersonDataSource} from './person-data-source'; +import {MdPaginator} from '@angular/material'; export type UserProperties = 'userId' | 'userName' | 'progress' | 'color' | undefined; @@ -19,13 +20,17 @@ export class DataTableDemo { changeReferences = false; highlights = new Set(); - constructor(public _peopleDatabase: PeopleDatabase) { + @ViewChild(MdPaginator) _paginator: MdPaginator; + + constructor(public _peopleDatabase: PeopleDatabase) { } + + ngOnInit() { this.connect(); } connect() { this.propertiesToDisplay = ['userId', 'userName', 'progress', 'color']; - this.dataSource = new PersonDataSource(this._peopleDatabase); + this.dataSource = new PersonDataSource(this._peopleDatabase, this._paginator); this._peopleDatabase.initialize(); } diff --git a/src/demo-app/data-table/person-data-source.ts b/src/demo-app/data-table/person-data-source.ts index 4fa67f98172d..0b60d68f8762 100644 --- a/src/demo-app/data-table/person-data-source.ts +++ b/src/demo-app/data-table/person-data-source.ts @@ -1,34 +1,58 @@ -import {CollectionViewer, DataSource} from '@angular/material'; +import {CollectionViewer, DataSource, MdPaginator} from '@angular/material'; import {Observable} from 'rxjs/Observable'; import {PeopleDatabase, UserData} from './people-database'; +import {BehaviorSubject} from 'rxjs/BehaviorSubject'; export class PersonDataSource extends DataSource { + /** Data that should be displayed by the table. */ + _displayData = new BehaviorSubject([]); + + /** Cached data provided by the display data. */ _renderedData: any[] = []; - constructor(private _peopleDatabase: PeopleDatabase) { + constructor(private _peopleDatabase: PeopleDatabase, + private _paginator: MdPaginator) { super(); + + // Subscribe to page changes and database changes by clearing the cached data and + // determining the updated display data. + Observable.merge(this._paginator.page, this._peopleDatabase.dataChange).subscribe(() => { + this._renderedData = []; + this.updateDisplayData(); + }); } connect(collectionViewer: CollectionViewer): Observable { - const changeStreams = Observable.combineLatest( - collectionViewer.viewChange, - this._peopleDatabase.dataChange); - return changeStreams.map((result: any[]) => { - const view: {start: number, end: number} = result[0]; + this.updateDisplayData(); - // Set the rendered rows length to the virtual page size. Fill in the data provided - // from the index start until the end index or pagination size, whichever is smaller. - this._renderedData.length = this._peopleDatabase.data.length; + const streams = [collectionViewer.viewChange, this._displayData]; + return Observable.combineLatest(streams) + .map((results: [{start: number, end: number}, UserData[]]) => { + const [view, data] = results; - const buffer = 20; - let rangeStart = Math.max(0, view.start - buffer); - let rangeEnd = Math.min(this._peopleDatabase.data.length, view.end + buffer); + // Set the rendered rows length to the virtual page size. Fill in the data provided + // from the index start until the end index or pagination size, whichever is smaller. + this._renderedData.length = data.length; - for (let i = rangeStart; i < rangeEnd; i++) { - this._renderedData[i] = this._peopleDatabase.data[i]; - } + const buffer = 20; + let rangeStart = Math.max(0, view.start - buffer); + let rangeEnd = Math.min(data.length, view.end + buffer); - return this._renderedData; - }); + for (let i = rangeStart; i < rangeEnd; i++) { + this._renderedData[i] = data[i]; + } + + return this._renderedData; + }); + } + + updateDisplayData() { + const data = this._peopleDatabase.data.slice(); + + // Grab the page's slice of data. + const startIndex = this._paginator.pageIndex * this._paginator.pageSize; + const paginatedData = data.splice(startIndex, this._paginator.pageSize); + + this._displayData.next(paginatedData); } } diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index 1a3d8737bfd3..9b8d42650299 100644 --- a/src/demo-app/demo-app-module.ts +++ b/src/demo-app/demo-app-module.ts @@ -61,6 +61,7 @@ import { MdListModule, MdMenuModule, MdNativeDateModule, + MdPaginatorModule, MdProgressBarModule, MdProgressSpinnerModule, MdRadioModule, @@ -98,6 +99,7 @@ import {TableHeaderDemo} from './data-table/table-header-demo'; MdListModule, MdMenuModule, MdCoreModule, + MdPaginatorModule, MdProgressBarModule, MdProgressSpinnerModule, MdRadioModule, diff --git a/src/lib/core/theming/_all-theme.scss b/src/lib/core/theming/_all-theme.scss index bfd0523981b2..2d2de07d1fb0 100644 --- a/src/lib/core/theming/_all-theme.scss +++ b/src/lib/core/theming/_all-theme.scss @@ -14,6 +14,7 @@ @import '../../input/input-theme'; @import '../../list/list-theme'; @import '../../menu/menu-theme'; +@import '../../paginator/paginator-theme'; @import '../../progress-bar/progress-bar-theme'; @import '../../progress-spinner/progress-spinner-theme'; @import '../../radio/radio-theme'; @@ -44,6 +45,7 @@ @include mat-input-theme($theme); @include mat-list-theme($theme); @include mat-menu-theme($theme); + @include mat-paginator-theme($theme); @include mat-progress-bar-theme($theme); @include mat-progress-spinner-theme($theme); @include mat-radio-theme($theme); diff --git a/src/lib/core/typography/_all-typography.scss b/src/lib/core/typography/_all-typography.scss index 9477fd9fb5b2..3ad42da491a5 100644 --- a/src/lib/core/typography/_all-typography.scss +++ b/src/lib/core/typography/_all-typography.scss @@ -12,6 +12,7 @@ @import '../../input/input-theme'; @import '../../list/list-theme'; @import '../../menu/menu-theme'; +@import '../../paginator/paginator-theme'; @import '../../progress-bar/progress-bar-theme'; @import '../../progress-spinner/progress-spinner-theme'; @import '../../radio/radio-theme'; @@ -42,6 +43,7 @@ @include mat-icon-typography($config); @include mat-input-typography($config); @include mat-menu-typography($config); + @include mat-paginator-typography($config); @include mat-progress-bar-typography($config); @include mat-progress-spinner-typography($config); @include mat-radio-typography($config); diff --git a/src/lib/module.ts b/src/lib/module.ts index f25636506fe7..af6cb9ad1f52 100644 --- a/src/lib/module.ts +++ b/src/lib/module.ts @@ -46,6 +46,7 @@ import {StyleModule} from './core/style/index'; import {MdDatepickerModule} from './datepicker/index'; import {CdkDataTableModule} from './core/data-table/index'; import {MdExpansionModule} from './expansion/index'; +import {MdPaginatorModule} from './paginator/index'; const MATERIAL_MODULES = [ MdAutocompleteModule, @@ -62,6 +63,7 @@ const MATERIAL_MODULES = [ MdInputModule, MdListModule, MdMenuModule, + MdPaginatorModule, MdProgressBarModule, MdProgressSpinnerModule, MdRadioModule, diff --git a/src/lib/paginator/_paginator-theme.scss b/src/lib/paginator/_paginator-theme.scss new file mode 100644 index 000000000000..1da4de91b21e --- /dev/null +++ b/src/lib/paginator/_paginator-theme.scss @@ -0,0 +1,42 @@ +@import '../core/theming/palette'; +@import '../core/theming/theming'; +@import '../core/typography/typography-utils'; + + +@mixin mat-paginator-theme($theme) { + $foreground: map-get($theme, foreground); + + .mat-paginator, + .mat-paginator-page-size .mat-select-trigger { + color: mat-color($foreground, secondary-text); + } + + .mat-paginator-increment, + .mat-paginator-decrement { + border-top: 2px solid mat-color($foreground, 'icon'); + border-right: 2px solid mat-color($foreground, 'icon'); + } + + .mat-icon-button[disabled] { + .mat-paginator-increment, + .mat-paginator-decrement { + border-color: mat-color($foreground, 'disabled'); + } + } +} + +@mixin mat-paginator-typography($config) { + .mat-paginator { + font: { + family: mat-font-family($config); + size: mat-font-size($config, caption); + } + } + + .mat-paginator-page-size .mat-select-trigger { + font: { + family: mat-font-family($config); + size: mat-font-size($config, caption); + } + } +} diff --git a/src/lib/paginator/index.ts b/src/lib/paginator/index.ts new file mode 100644 index 000000000000..70de22c73115 --- /dev/null +++ b/src/lib/paginator/index.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {FormsModule} from '@angular/forms'; +import {MdCommonModule, OverlayModule} from '../core'; +import {MdButtonModule} from '../button/index'; +import {MdSelectModule} from '../select/index'; +import {MdPaginator} from './paginator'; +import {MdPaginatorIntl} from './paginator-intl'; +import {MdTooltipModule} from '../tooltip/index'; + + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + MdButtonModule, + MdSelectModule, + MdTooltipModule, + ], + exports: [MdPaginator], + declarations: [MdPaginator], + providers: [MdPaginatorIntl], +}) +export class MdPaginatorModule {} + + +export * from './paginator'; diff --git a/src/lib/paginator/paginator-intl.ts b/src/lib/paginator/paginator-intl.ts new file mode 100644 index 000000000000..02e188f3d6d5 --- /dev/null +++ b/src/lib/paginator/paginator-intl.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Injectable} from '@angular/core'; + +/** + * To modify the labels and text displayed, create a new instance of MdPaginatorIntl and + * include it in a custom provider + */ +@Injectable() +export class MdPaginatorIntl { + /** A label for the page size selector. */ + itemsPerPageLabel = 'Items per page:'; + + /** A label for the button that increments the current page. */ + nextPageLabel = 'Next page'; + + /** A label for the button that decrements the current page. */ + previousPageLabel = 'Previous page'; + + /** A label for the range of items within the current page and the length of the whole list. */ + getRangeLabel = (page: number, pageSize: number, length: number) => { + if (length == 0 || pageSize == 0) { return `0 of ${length}`; } + + length = Math.max(length, 0); + + const startIndex = page * pageSize; + + // If the start index exceeds the list length, do not try and fix the end index to the end. + const endIndex = startIndex < length ? + Math.min(startIndex + pageSize, length) : + startIndex + pageSize; + + return `${startIndex + 1} - ${endIndex} of ${length}`; + } +} diff --git a/src/lib/paginator/paginator.html b/src/lib/paginator/paginator.html new file mode 100644 index 000000000000..6460253a460d --- /dev/null +++ b/src/lib/paginator/paginator.html @@ -0,0 +1,40 @@ +
+
+ {{_intl.itemsPerPageLabel}} +
+ + + + {{pageSizeOption}} + + + +
{{pageSize}}
+
+ +
+ {{_intl.getRangeLabel(pageIndex, pageSize, length)}} +
+ + + diff --git a/src/lib/paginator/paginator.md b/src/lib/paginator/paginator.md new file mode 100644 index 000000000000..394353bdc911 --- /dev/null +++ b/src/lib/paginator/paginator.md @@ -0,0 +1,26 @@ +`` is a navigation for pages information, typically used with a data-table. + + + +### Basic use +Each paginator instance requires: +* The number of items per page (default set to 50) +* The total number of items being paged + +The current page index defaults to 0, but can be explicitly set via pageIndex. + +When the user interacts with the paginator, a `PageEvent` will be fired that can be used to update +any associated data view. + +### Page size options +The paginator displays a dropdown of page sizes for the user to choose from. The options for this +dropdown can be set via `pageSizeOptions` + +The current pageSize will always appear in the dropdown, even if it is not included in pageSizeOptions. + +### Internationalization +The labels for the paginator can be customized by providing your own instance of `MdPaginatorIntl`. +This will allow you to change the following: + 1. The label for the length of each page. + 2. The range text displayed to the user. + 3. The tooltip messages on the navigation buttons. diff --git a/src/lib/paginator/paginator.scss b/src/lib/paginator/paginator.scss new file mode 100644 index 000000000000..d547b5144cb5 --- /dev/null +++ b/src/lib/paginator/paginator.scss @@ -0,0 +1,81 @@ +$mat-paginator-height: 56px; +$mat-paginator-padding: 0 8px; + +$mat-paginator-items-per-page-label-margin: 0 4px; +$mat-paginator-selector-margin: 0 4px; +$mat-paginator-selector-trigger-min-width: 56px; + +$mat-paginator-range-label-margin: 0 32px; + +$mat-paginator-button-margin: 8px; +$mat-paginator-button-icon-height: 8px; +$mat-paginator-button-icon-width: 8px; + +$mat-paginator-button-decrement-icon-margin: 12px; +$mat-paginator-button-increment-icon-margin: 16px; + +.mat-paginator { + display: flex; + align-items: center; + justify-content: flex-end; + min-height: $mat-paginator-height; + padding: $mat-paginator-padding; +} + +.mat-paginator-page-size { + display: flex; + align-items: center; +} + +.mat-paginator-page-size-label { + margin: $mat-paginator-items-per-page-label-margin; +} + +.mat-paginator-page-size-select { + margin: $mat-paginator-selector-margin; + + .mat-select-trigger { + min-width: $mat-paginator-selector-trigger-min-width; + } +} + +.mat-paginator-range-label { + margin: $mat-paginator-range-label-margin; +} + +.mat-paginator-increment-button + .mat-paginator-increment-button { + margin: 0 0 0 $mat-paginator-button-margin; + + [dir='rtl'] & { + margin: 0 $mat-paginator-button-margin 0 0; + } +} + +.mat-paginator-increment, +.mat-paginator-decrement { + width: $mat-paginator-button-icon-width; + height: $mat-paginator-button-icon-height; +} + +.mat-paginator-decrement, +[dir='rtl'] .mat-paginator-increment { + transform: rotate(45deg); +} +.mat-paginator-increment, +[dir='rtl'] .mat-paginator-decrement { + transform: rotate(225deg); +} + +.mat-paginator-decrement { + margin-left: $mat-paginator-button-decrement-icon-margin; + [dir='rtl'] & { + margin-right: $mat-paginator-button-decrement-icon-margin; + } +} + +.mat-paginator-increment { + margin-left: $mat-paginator-button-increment-icon-margin; + [dir='rtl'] & { + margin-right: $mat-paginator-button-increment-icon-margin; + } +} diff --git a/src/lib/paginator/paginator.spec.ts b/src/lib/paginator/paginator.spec.ts new file mode 100644 index 000000000000..c00d8ad20ad5 --- /dev/null +++ b/src/lib/paginator/paginator.spec.ts @@ -0,0 +1,279 @@ +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {MdPaginatorModule} from './index'; +import {MdPaginator, PageEvent} from './paginator'; +import {Component, ElementRef, ViewChild} from '@angular/core'; +import {MdPaginatorIntl} from './paginator-intl'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {customMatchers} from '../core/testing/jasmine-matchers'; +import {dispatchMouseEvent} from '../core/testing/dispatch-events'; + + +describe('MdPaginator', () => { + let fixture: ComponentFixture; + let component: MdPaginatorApp; + let paginator: MdPaginator; + + beforeEach(async(() => { + jasmine.addMatchers(customMatchers); + + TestBed.configureTestingModule({ + imports: [ + MdPaginatorModule, + NoopAnimationsModule, + ], + declarations: [ + MdPaginatorApp, + MdPaginatorWithoutPageSizeApp, + MdPaginatorWithoutOptionsApp, + ], + providers: [MdPaginatorIntl] + }).compileComponents(); + + fixture = TestBed.createComponent(MdPaginatorApp); + component = fixture.componentInstance; + paginator = component.mdPaginator; + + fixture.detectChanges(); + })); + + describe('with the default internationalization provider', () => { + it('should show the right range text', () => { + const rangeElement = fixture.nativeElement.querySelector('.mat-paginator-range-label'); + + // View second page of list of 100, each page contains 10 items. + component.length = 100; + component.pageSize = 10; + component.pageIndex = 1; + fixture.detectChanges(); + expect(rangeElement.innerText).toBe('11 - 20 of 100'); + + // View third page of list of 200, each page contains 20 items. + component.length = 200; + component.pageSize = 20; + component.pageIndex = 2; + fixture.detectChanges(); + expect(rangeElement.innerText).toBe('41 - 60 of 200'); + + // View first page of list of 0, each page contains 5 items. + component.length = 0; + component.pageSize = 5; + component.pageIndex = 2; + fixture.detectChanges(); + expect(rangeElement.innerText).toBe('0 of 0'); + + // View third page of list of 12, each page contains 5 items. + component.length = 12; + component.pageSize = 5; + component.pageIndex = 2; + fixture.detectChanges(); + expect(rangeElement.innerText).toBe('11 - 12 of 12'); + + // View third page of list of 10, each page contains 5 items. + component.length = 10; + component.pageSize = 5; + component.pageIndex = 2; + fixture.detectChanges(); + expect(rangeElement.innerText).toBe('11 - 15 of 10'); + + // View third page of list of -5, each page contains 5 items. + component.length = -5; + component.pageSize = 5; + component.pageIndex = 2; + fixture.detectChanges(); + expect(rangeElement.innerText).toBe('11 - 15 of 0'); + }); + + it('should show right aria-labels for select and buttons', () => { + const select = fixture.nativeElement.querySelector('.mat-select'); + expect(select.getAttribute('aria-label')).toBe('Items per page:'); + + const prevButton = fixture.nativeElement.querySelector('.mat-paginator-navigation-previous'); + expect(prevButton.getAttribute('aria-label')).toBe('Previous page'); + + const nextButton = fixture.nativeElement.querySelector('.mat-paginator-navigation-next'); + expect(nextButton.getAttribute('aria-label')).toBe('Next page'); + }); + }); + + describe('when navigating with the navigation buttons', () => { + it('should be able to go to the next page', () => { + expect(paginator.pageIndex).toBe(0); + + component.clickNextButton(); + + expect(paginator.pageIndex).toBe(1); + expect(component.latestPageEvent ? component.latestPageEvent.pageIndex : null).toBe(1); + }); + + it('should be able to go to the previous page', () => { + paginator.pageIndex = 1; + fixture.detectChanges(); + expect(paginator.pageIndex).toBe(1); + + component.clickPreviousButton(); + + expect(paginator.pageIndex).toBe(0); + expect(component.latestPageEvent ? component.latestPageEvent.pageIndex : null).toBe(0); + }); + + it('should disable navigating to the next page if at first page', () => { + component.goToLastPage(); + fixture.detectChanges(); + expect(paginator.pageIndex).toBe(10); + expect(paginator.hasNextPage()).toBe(false); + + component.latestPageEvent = null; + component.clickNextButton(); + + expect(component.latestPageEvent).toBe(null); + expect(paginator.pageIndex).toBe(10); + }); + + it('should disable navigating to the previous page if at first page', () => { + expect(paginator.pageIndex).toBe(0); + expect(paginator.hasPreviousPage()).toBe(false); + + component.latestPageEvent = null; + component.clickPreviousButton(); + + expect(component.latestPageEvent).toBe(null); + expect(paginator.pageIndex).toBe(0); + }); + }); + + it('should default the page size options to the page size if no options provided', () => { + const withoutOptionsAppFixture = TestBed.createComponent(MdPaginatorWithoutOptionsApp); + withoutOptionsAppFixture.detectChanges(); + + expect(withoutOptionsAppFixture.componentInstance.mdPaginator._displayedPageSizeOptions) + .toEqual([10]); + }); + + it('should show a sorted list of page size options including the current page size', () => { + expect(paginator._displayedPageSizeOptions).toEqual([5, 10, 25, 100]); + + component.pageSize = 30; + fixture.detectChanges(); + expect(paginator.pageSizeOptions).toEqual([5, 10, 25, 100]); + expect(paginator._displayedPageSizeOptions).toEqual([5, 10, 25, 30, 100]); + + component.pageSizeOptions = [100, 25, 10, 5]; + fixture.detectChanges(); + expect(paginator._displayedPageSizeOptions).toEqual([5, 10, 25, 30, 100]); + }); + + it('should be able to change the page size while keeping the first item present', () => { + // Start on the third page of a list of 100 with a page size of 10. + component.pageIndex = 4; + component.pageSize = 10; + component.length = 100; + fixture.detectChanges(); + + // The first item of the page should be item with index 40 + let firstPageItemIndex: number | null = paginator.pageIndex * paginator.pageSize; + expect(firstPageItemIndex).toBe(40); + + // The first item on the page is now 25. Change the page size to 25 so that we should now be + // on the second page where the top item is index 25. + paginator._changePageSize(25); + let paginationEvent = component.latestPageEvent; + firstPageItemIndex = paginationEvent ? + paginationEvent.pageIndex * paginationEvent.pageSize : null; + expect(firstPageItemIndex).toBe(25); + expect(paginationEvent ? paginationEvent.pageIndex : null).toBe(1); + + // The first item on the page is still 25. Change the page size to 8 so that we should now be + // on the fourth page where the top item is index 24. + paginator._changePageSize(8); + paginationEvent = component.latestPageEvent; + firstPageItemIndex = paginationEvent ? + paginationEvent.pageIndex * paginationEvent.pageSize : null; + expect(firstPageItemIndex).toBe(24); + expect(paginationEvent ? paginationEvent.pageIndex : null).toBe(3); + + // The first item on the page is 24. Change the page size to 16 so that we should now be + // on the first page where the top item is index 0. + paginator._changePageSize(25); + paginationEvent = component.latestPageEvent; + firstPageItemIndex = paginationEvent ? + paginationEvent.pageIndex * paginationEvent.pageSize : null; + expect(firstPageItemIndex).toBe(0); + expect(paginationEvent ? paginationEvent.pageIndex : null).toBe(0); + }); + + it('should show a select only if there are multiple options', () => { + expect(paginator._displayedPageSizeOptions).toEqual([5, 10, 25, 100]); + expect(fixture.nativeElement.querySelector('.mat-select')).not.toBeNull(); + + // Remove options so that the paginator only uses the current page size (10) as an option. + // Should no longer show the select component since there is only one option. + component.pageSizeOptions = []; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.mat-select')).toBeNull(); + }); +}); + +@Component({ + template: ` + + + `, +}) +class MdPaginatorApp { + pageIndex = 0; + pageSize = 10; + pageSizeOptions = [5, 10, 25, 100]; + length = 100; + + latestPageEvent: PageEvent | null; + + @ViewChild(MdPaginator) mdPaginator: MdPaginator; + + constructor(private _elementRef: ElementRef) { } + + clickPreviousButton() { + const previousButton = + this._elementRef.nativeElement.querySelector('.mat-paginator-navigation-previous'); + dispatchMouseEvent(previousButton, 'click'); + } + + clickNextButton() { + const nextButton = + this._elementRef.nativeElement.querySelector('.mat-paginator-navigation-next'); + dispatchMouseEvent(nextButton, 'click'); } + + goToLastPage() { + this.pageIndex = Math.ceil(this.length / this.pageSize); + } +} + +@Component({ + template: ` + + `, +}) +class MdPaginatorWithoutPageSizeOrOptionsApp { + @ViewChild(MdPaginator) mdPaginator: MdPaginator; +} + +@Component({ + template: ` + + `, +}) +class MdPaginatorWithoutPageSizeApp { + @ViewChild(MdPaginator) mdPaginator: MdPaginator; +} + +@Component({ + template: ` + + `, +}) +class MdPaginatorWithoutOptionsApp { + @ViewChild(MdPaginator) mdPaginator: MdPaginator; +} diff --git a/src/lib/paginator/paginator.ts b/src/lib/paginator/paginator.ts new file mode 100644 index 000000000000..47a7158b4783 --- /dev/null +++ b/src/lib/paginator/paginator.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, + ViewEncapsulation +} from '@angular/core'; +import {MdPaginatorIntl} from './paginator-intl'; +import {MATERIAL_COMPATIBILITY_MODE} from '../core'; + +/** + * Change event object that is emitted when the user selects a + * different page size or navigates to another page. + */ +export class PageEvent { + pageIndex: number; + pageSize: number; + length: number; +} + +/** + * Component to provide navigation between paged information. Displays the size of the current + * page, user-selectable options to change that size, what items are being shown, and + * navigational button to go to the previous or next page. + */ +@Component({ + moduleId: module.id, + selector: 'md-paginator, mat-paginator', + templateUrl: 'paginator.html', + styleUrls: ['paginator.css'], + host: { + 'class': 'mat-paginator', + }, + providers: [ + {provide: MATERIAL_COMPATIBILITY_MODE, useValue: false} + ], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, +}) +export class MdPaginator implements OnInit { + private _initialized: boolean; + + /** The zero-based page index of the displayed list of items. Defaulted to 0. */ + @Input() pageIndex: number = 0; + + /** The length of the total number of items that are being paginated. Defaulted to 0. */ + @Input() length: number = 0; + + /** Number of items to display on a page. By default set to 50. */ + @Input() + get pageSize(): number { return this._pageSize; } + set pageSize(pageSize: number) { + this._pageSize = pageSize; + this._updateDisplayedPageSizeOptions(); + } + private _pageSize: number = 50; + + /** The set of provided page size options to display to the user. */ + @Input() + get pageSizeOptions(): number[] { return this._pageSizeOptions; } + set pageSizeOptions(pageSizeOptions: number[]) { + this._pageSizeOptions = pageSizeOptions; + this._updateDisplayedPageSizeOptions(); + } + private _pageSizeOptions: number[] = []; + + /** Event emitted when the paginator changes the page size or page index. */ + @Output() page = new EventEmitter(); + + /** Displayed set of page size options. Will be sorted and include current page size. */ + _displayedPageSizeOptions: number[]; + + constructor(public _intl: MdPaginatorIntl) { } + + ngOnInit() { + this._initialized = true; + this._updateDisplayedPageSizeOptions(); + } + + /** Increments the page index to the next page index if a next page exists. */ + nextPage() { + if (!this.hasNextPage()) { return; } + this.pageIndex++; + this._emitPageEvent(); + } + + /** Decrements the page index to the previous page index if a next page exists. */ + previousPage() { + if (!this.hasPreviousPage()) { return; } + this.pageIndex--; + this._emitPageEvent(); + } + + /** Returns true if the user can go to the next page. */ + hasPreviousPage() { + return this.pageIndex >= 1 && this.pageSize != 0; + } + + /** Returns true if the user can go to the next page. */ + hasNextPage() { + const numberOfPages = Math.ceil(this.length / this.pageSize) - 1; + return this.pageIndex < numberOfPages && this.pageSize != 0; + } + + /** + * Changes the page size so that the first item displayed on the page will still be + * displayed using the new page size. + * + * For example, if the page size is 10 and on the second page (items indexed 10-19) then + * switching so that the page size is 5 will set the third page as the current page so + * that the 10th item will still be displayed. + */ + _changePageSize(pageSize: number) { + // Current page needs to be updated to reflect the new page size. Navigate to the page + // containing the previous page's first item. + const startIndex = this.pageIndex * this.pageSize; + this.pageIndex = Math.floor(startIndex / pageSize) || 0; + + this.pageSize = pageSize; + this._emitPageEvent(); + } + + /** + * Updates the list of page size options to display to the user. Includes making sure that + * the page size is an option and that the list is sorted. + */ + private _updateDisplayedPageSizeOptions() { + if (!this._initialized) { return; } + + this._displayedPageSizeOptions = this.pageSizeOptions.slice(); + if (this._displayedPageSizeOptions.indexOf(this.pageSize) == -1) { + this._displayedPageSizeOptions.push(this.pageSize); + } + + // Sort the numbers using a number-specific sort function. + this._displayedPageSizeOptions.sort((a, b) => a - b); + } + + /** Emits an event notifying that a change of the paginator's properties has been triggered. */ + private _emitPageEvent() { + this.page.next({ + pageIndex: this.pageIndex, + pageSize: this.pageSize, + length: this.length + }); + } +} diff --git a/src/lib/public_api.ts b/src/lib/public_api.ts index e28998467e46..bf44dbe51ea0 100644 --- a/src/lib/public_api.ts +++ b/src/lib/public_api.ts @@ -30,6 +30,7 @@ export * from './icon/index'; export * from './input/index'; export * from './list/index'; export * from './menu/index'; +export * from './paginator/index'; export * from './progress-bar/index'; export * from './progress-spinner/index'; export * from './radio/index'; diff --git a/src/material-examples/example-module.ts b/src/material-examples/example-module.ts index e086284f382e..582aa274be58 100644 --- a/src/material-examples/example-module.ts +++ b/src/material-examples/example-module.ts @@ -15,8 +15,8 @@ import { ProgressBarConfigurableExample } from './progress-bar-configurable/progress-bar-configurable-example'; import { - DialogOverviewExampleDialog, - DialogOverviewExample + DialogOverviewExample, + DialogOverviewExampleDialog } from './dialog-overview/dialog-overview-example'; import {RadioNgModelExample} from './radio-ng-model/radio-ng-model-example'; import {CardFancyExample} from './card-fancy/card-fancy-example'; @@ -36,12 +36,12 @@ import { import {ListSectionsExample} from './list-sections/list-sections-example'; import {SnackBarOverviewExample} from './snack-bar-overview/snack-bar-overview-example'; import { - DialogResultExampleDialog, - DialogResultExample + DialogResultExample, + DialogResultExampleDialog } from './dialog-result/dialog-result-example'; import { - DialogElementsExampleDialog, - DialogElementsExample + DialogElementsExample, + DialogElementsExampleDialog } from './dialog-elements/dialog-elements-example'; import {TooltipOverviewExample} from './tooltip-overview/tooltip-overview-example'; import {ButtonToggleOverviewExample} from './button-toggle-overview/button-toggle-overview-example'; @@ -68,20 +68,22 @@ import {SelectOverviewExample} from './select-overview/select-overview-example'; import {ChipsOverviewExample} from './chips-overview/chips-overview-example'; import {ChipsStackedExample} from './chips-stacked/chips-stacked-example'; import {SelectFormExample} from './select-form/select-form-example'; +import {PaginatorOverviewExample} from './paginator-overview/paginator-overview-example'; import {DatepickerOverviewExample} from './datepicker-overview/datepicker-overview-example'; +import { + PaginatorConfigurableExample +} from './paginator-configurable/paginator-configurable-example'; import {InputOverviewExample} from './input-overview/input-overview-example'; import {InputErrorsExample} from './input-errors/input-errors-example'; import {InputFormExample} from './input-form/input-form-example'; import {InputPrefixSuffixExample} from './input-prefix-suffix/input-prefix-suffix-example'; import {InputHintExample} from './input-hint/input-hint-example'; - import { MdAutocompleteModule, MdButtonModule, MdButtonToggleModule, MdCardModule, MdCheckboxModule, MdChipsModule, MdDatepickerModule, MdDialogModule, MdGridListModule, MdIconModule, MdInputModule, - MdListModule, MdMenuModule, MdProgressBarModule, MdProgressSpinnerModule, MdRadioModule, - MdSelectModule, MdSidenavModule, MdSliderModule, MdSlideToggleModule, MdSnackBarModule, - MdTabsModule, - MdToolbarModule, MdTooltipModule + MdListModule, MdMenuModule, MdPaginatorModule, MdProgressBarModule, MdProgressSpinnerModule, + MdRadioModule, MdSelectModule, MdSidenavModule, MdSliderModule, MdSlideToggleModule, + MdSnackBarModule, MdTabsModule, MdToolbarModule, MdTooltipModule } from '@angular/material'; export interface LiveExample { @@ -143,6 +145,11 @@ export const EXAMPLE_COMPONENTS = { 'list-sections': {title: 'List with sections', component: ListSectionsExample}, 'menu-icons': {title: 'Menu with icons', component: MenuIconsExample}, 'menu-overview': {title: 'Basic menu', component: MenuOverviewExample}, + 'paginator-overview': {title: 'Paginator', component: PaginatorOverviewExample}, + 'paginator-configurable': { + title: 'Configurable paginator', + component: PaginatorConfigurableExample + }, 'progress-bar-configurable': { title: 'Configurable progress-bar', component: ProgressBarConfigurableExample @@ -201,6 +208,7 @@ export const EXAMPLE_COMPONENTS = { MdInputModule, MdListModule, MdMenuModule, + MdPaginatorModule, MdProgressBarModule, MdProgressSpinnerModule, MdRadioModule, @@ -252,6 +260,7 @@ export const EXAMPLE_LIST = [ ListSectionsExample, MenuIconsExample, MenuOverviewExample, + PaginatorOverviewExample, ProgressBarConfigurableExample, ProgressBarOverviewExample, ProgressSpinnerConfigurableExample, diff --git a/src/material-examples/paginator-configurable/paginator-configurable-example.css b/src/material-examples/paginator-configurable/paginator-configurable-example.css new file mode 100644 index 000000000000..7432308753e6 --- /dev/null +++ b/src/material-examples/paginator-configurable/paginator-configurable-example.css @@ -0,0 +1 @@ +/** No CSS for this example */ diff --git a/src/material-examples/paginator-configurable/paginator-configurable-example.html b/src/material-examples/paginator-configurable/paginator-configurable-example.html new file mode 100644 index 000000000000..b3bda8043b0e --- /dev/null +++ b/src/material-examples/paginator-configurable/paginator-configurable-example.html @@ -0,0 +1,28 @@ + + List length: + + + + + Page size: + + + + Page size options: + + + + + + +
+

Page Change Event Properties

+
List length: {{pageEvent.length}}
+
Page size: {{pageEvent.pageSize}}
+
Page index: {{pageEvent.pageIndex}}
+
\ No newline at end of file diff --git a/src/material-examples/paginator-configurable/paginator-configurable-example.ts b/src/material-examples/paginator-configurable/paginator-configurable-example.ts new file mode 100644 index 000000000000..0e432eb6c8eb --- /dev/null +++ b/src/material-examples/paginator-configurable/paginator-configurable-example.ts @@ -0,0 +1,20 @@ +import {Component} from '@angular/core'; +import {PageEvent} from '@angular/material'; + +@Component({ + selector: 'paginator-configurable-example', + templateUrl: 'paginator-configurable-example.html', +}) +export class PaginatorConfigurableExample { + // MdPaginator Inputs + length = 100; + pageSize = 10; + pageSizeOptions = [5, 10, 25, 100]; + + // MdPaginator Output + pageEvent: PageEvent; + + setPageSizeOptions(setPageSizeOptionsInput: string) { + this.pageSizeOptions = setPageSizeOptionsInput.split(',').map(str => +str); + } +} diff --git a/src/material-examples/paginator-overview/paginator-overview-example.css b/src/material-examples/paginator-overview/paginator-overview-example.css new file mode 100644 index 000000000000..7432308753e6 --- /dev/null +++ b/src/material-examples/paginator-overview/paginator-overview-example.css @@ -0,0 +1 @@ +/** No CSS for this example */ diff --git a/src/material-examples/paginator-overview/paginator-overview-example.html b/src/material-examples/paginator-overview/paginator-overview-example.html new file mode 100644 index 000000000000..b80ef9183c97 --- /dev/null +++ b/src/material-examples/paginator-overview/paginator-overview-example.html @@ -0,0 +1,4 @@ + + diff --git a/src/material-examples/paginator-overview/paginator-overview-example.ts b/src/material-examples/paginator-overview/paginator-overview-example.ts new file mode 100644 index 000000000000..d563732d6233 --- /dev/null +++ b/src/material-examples/paginator-overview/paginator-overview-example.ts @@ -0,0 +1,8 @@ +import {Component} from '@angular/core'; + + +@Component({ + selector: 'paginator-overview-example', + templateUrl: 'paginator-overview-example.html', +}) +export class PaginatorOverviewExample {} diff --git a/tools/dgeni/index.js b/tools/dgeni/index.js index d22b2c792b9f..a4fa70894c6b 100644 --- a/tools/dgeni/index.js +++ b/tools/dgeni/index.js @@ -97,6 +97,7 @@ let apiDocsPackage = new DgeniPackage('material2-api-docs', dgeniPackageDeps) 'input/index.ts', 'list/index.ts', 'menu/index.ts', + 'paginator/index.ts', 'progress-bar/index.ts', 'progress-spinner/index.ts', 'radio/index.ts',