From b328d36933f41fb292c36650ad0a7dffbb7f889f Mon Sep 17 00:00:00 2001 From: Andrew Seguin Date: Tue, 27 Jun 2017 16:38:34 -0700 Subject: [PATCH] feat(sort): add ability to manage and display sorting (#5307) * feat(sort): add sortable * checkin * checkin * feat(sort): add sort header * overrides, tests * format demo html * add ngif to screenready label * add new line to scss * fix tests * fix types * fix types * shorten coerce import * comments * comments * rebase * specialize intl to header; make public * remove reverse * button type and onpush * rename sort directions (shorten) * small changes * remove consolelog * screenreader --- src/demo-app/data-table/data-table-demo.html | 23 +- src/demo-app/data-table/data-table-demo.scss | 6 + src/demo-app/data-table/data-table-demo.ts | 6 +- src/demo-app/data-table/person-data-source.ts | 38 ++- src/demo-app/demo-app-module.ts | 2 + .../unique-selection-dispatcher.ts | 2 +- src/lib/core/data-table/data-table.ts | 7 +- src/lib/module.ts | 2 + src/lib/public_api.ts | 1 + src/lib/sort/index.ts | 26 ++ src/lib/sort/sort-direction.ts | 9 + src/lib/sort/sort-errors.ts | 22 ++ src/lib/sort/sort-header-intl.ts | 26 ++ src/lib/sort/sort-header.html | 20 ++ src/lib/sort/sort-header.scss | 74 +++++ src/lib/sort/sort-header.ts | 94 +++++++ src/lib/sort/sort.spec.ts | 263 ++++++++++++++++++ src/lib/sort/sort.ts | 115 ++++++++ 18 files changed, 721 insertions(+), 15 deletions(-) create mode 100644 src/lib/sort/index.ts create mode 100644 src/lib/sort/sort-direction.ts create mode 100644 src/lib/sort/sort-errors.ts create mode 100644 src/lib/sort/sort-header-intl.ts create mode 100644 src/lib/sort/sort-header.html create mode 100644 src/lib/sort/sort-header.scss create mode 100644 src/lib/sort/sort-header.ts create mode 100644 src/lib/sort/sort.spec.ts create mode 100644 src/lib/sort/sort.ts diff --git a/src/demo-app/data-table/data-table-demo.html b/src/demo-app/data-table/data-table-demo.html index 728a2ae7b597..36a38c8e5cd9 100644 --- a/src/demo-app/data-table/data-table-demo.html +++ b/src/demo-app/data-table/data-table-demo.html @@ -33,17 +33,25 @@ (toggleColorColumn)="toggleColorColumn()"> - + - ID + + ID + {{row.id}} - Progress + + Progress +
{{row.progress}}%
@@ -57,13 +65,18 @@ - Name + + Name + {{row.name}} - Color + + Color + {{row.color}} diff --git a/src/demo-app/data-table/data-table-demo.scss b/src/demo-app/data-table/data-table-demo.scss index a6ebd7848764..a9abc92603d5 100644 --- a/src/demo-app/data-table/data-table-demo.scss +++ b/src/demo-app/data-table/data-table-demo.scss @@ -63,6 +63,10 @@ font-size: 12px; font-weight: bold; color: rgba(0, 0, 0, 0.54); + + &.mat-sort-header-sorted { + color: black; + } } .cdk-cell { @@ -73,6 +77,8 @@ /* Column and cell styles */ .cdk-column-userId { max-width: 32px; + text-align: right; + justify-content: flex-end; } .cdk-column-userName { diff --git a/src/demo-app/data-table/data-table-demo.ts b/src/demo-app/data-table/data-table-demo.ts index af13af2199a0..098c5b48b35d 100644 --- a/src/demo-app/data-table/data-table-demo.ts +++ b/src/demo-app/data-table/data-table-demo.ts @@ -2,6 +2,7 @@ import {Component, ViewChild} from '@angular/core'; import {PeopleDatabase, UserData} from './people-database'; import {PersonDataSource} from './person-data-source'; import {MdPaginator} from '@angular/material'; +import {MdSort} from '@angular/material'; export type UserProperties = 'userId' | 'userName' | 'progress' | 'color' | undefined; @@ -22,6 +23,8 @@ export class DataTableDemo { @ViewChild(MdPaginator) _paginator: MdPaginator; + @ViewChild(MdSort) sort: MdSort; + constructor(public _peopleDatabase: PeopleDatabase) { } ngOnInit() { @@ -30,7 +33,8 @@ export class DataTableDemo { connect() { this.propertiesToDisplay = ['userId', 'userName', 'progress', 'color']; - this.dataSource = new PersonDataSource(this._peopleDatabase, this._paginator); + this.dataSource = new PersonDataSource(this._peopleDatabase, + this._paginator, this.sort); 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 64014dd638e6..d2e4fd515121 100644 --- a/src/demo-app/data-table/person-data-source.ts +++ b/src/demo-app/data-table/person-data-source.ts @@ -1,7 +1,9 @@ -import {CollectionViewer, DataSource, MdPaginator} from '@angular/material'; +import {CollectionViewer, DataSource, MdPaginator, MdSort} from '@angular/material'; import {Observable} from 'rxjs/Observable'; import {PeopleDatabase, UserData} from './people-database'; import {BehaviorSubject} from 'rxjs/BehaviorSubject'; +import 'rxjs/add/observable/combineLatest'; +import 'rxjs/add/observable/merge'; import 'rxjs/add/operator/map'; import 'rxjs/add/observable/merge'; import 'rxjs/add/observable/combineLatest'; @@ -15,12 +17,15 @@ export class PersonDataSource extends DataSource { _renderedData: any[] = []; constructor(private _peopleDatabase: PeopleDatabase, - private _paginator: MdPaginator) { + private _paginator: MdPaginator, + private _sort: MdSort) { super(); - // Subscribe to page changes and database changes by clearing the cached data and + // Subscribe to paging, sorting, and database changes by clearing the cached data and // determining the updated display data. - Observable.merge(this._paginator.page, this._peopleDatabase.dataChange).subscribe(() => { + Observable.merge(this._paginator.page, + this._peopleDatabase.dataChange, + this._sort.mdSortChange).subscribe(() => { this._renderedData = []; this.updateDisplayData(); }); @@ -51,7 +56,7 @@ export class PersonDataSource extends DataSource { } updateDisplayData() { - const data = this._peopleDatabase.data.slice(); + const data = this.getSortedData(); // Grab the page's slice of data. const startIndex = this._paginator.pageIndex * this._paginator.pageSize; @@ -59,4 +64,27 @@ export class PersonDataSource extends DataSource { this._displayData.next(paginatedData); } + + /** Returns a sorted copy of the database data. */ + getSortedData(): UserData[] { + const data = this._peopleDatabase.data.slice(); + if (!this._sort.active || this._sort.direction == '') { return data; } + + return data.sort((a, b) => { + let propertyA: number|string = ''; + let propertyB: number|string = ''; + + switch (this._sort.active) { + case 'userId': [propertyA, propertyB] = [a.id, b.id]; break; + case 'userName': [propertyA, propertyB] = [a.name, b.name]; break; + case 'progress': [propertyA, propertyB] = [a.progress, b.progress]; break; + case 'color': [propertyA, propertyB] = [a.color, b.color]; break; + } + + let valueA = isNaN(+propertyA) ? propertyA : +propertyA; + let valueB = isNaN(+propertyB) ? propertyB : +propertyB; + + return (valueA < valueB ? -1 : 1) * (this._sort.direction == 'asc' ? 1 : -1); + }); + } } diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index 9b8d42650299..dc3557f834a1 100644 --- a/src/demo-app/demo-app-module.ts +++ b/src/demo-app/demo-app-module.ts @@ -71,6 +71,7 @@ import { MdSliderModule, MdSlideToggleModule, MdSnackBarModule, + MdSortModule, MdTabsModule, MdToolbarModule, MdTooltipModule, @@ -109,6 +110,7 @@ import {TableHeaderDemo} from './data-table/table-header-demo'; MdSlideToggleModule, MdSliderModule, MdSnackBarModule, + MdSortModule, MdTabsModule, MdToolbarModule, MdTooltipModule, diff --git a/src/lib/core/coordination/unique-selection-dispatcher.ts b/src/lib/core/coordination/unique-selection-dispatcher.ts index 6f96f5713f74..0f6ed31d1594 100644 --- a/src/lib/core/coordination/unique-selection-dispatcher.ts +++ b/src/lib/core/coordination/unique-selection-dispatcher.ts @@ -38,7 +38,7 @@ export class UniqueSelectionDispatcher { /** * Listen for future changes to item selection. - * @return Function used to unregister listener + * @return Function used to deregister listener **/ listen(listener: UniqueSelectionDispatcherListener): () => void { this._listeners.push(listener); diff --git a/src/lib/core/data-table/data-table.ts b/src/lib/core/data-table/data-table.ts index 1a629bbf07e7..13ce573050f4 100644 --- a/src/lib/core/data-table/data-table.ts +++ b/src/lib/core/data-table/data-table.ts @@ -213,14 +213,15 @@ export class CdkTable implements CollectionViewer { ngAfterViewInit() { // Find and construct an iterable differ that can be used to find the diff in an array. this._dataDiffer = this._differs.find([]).create(this._trackByFn); - - this._renderHeaderRow(); this._isViewInitialized = true; } ngDoCheck() { if (this._isViewInitialized && this.dataSource && !this._renderChangeSubscription) { - this._observeRenderChanges(); + this._renderHeaderRow(); + if (this.dataSource && !this._renderChangeSubscription) { + this._observeRenderChanges(); + } } } diff --git a/src/lib/module.ts b/src/lib/module.ts index af6cb9ad1f52..f64de2a236c0 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 {MdSortModule} from './sort/index'; import {MdPaginatorModule} from './paginator/index'; const MATERIAL_MODULES = [ @@ -73,6 +74,7 @@ const MATERIAL_MODULES = [ MdSliderModule, MdSlideToggleModule, MdSnackBarModule, + MdSortModule, MdTabsModule, MdToolbarModule, MdTooltipModule, diff --git a/src/lib/public_api.ts b/src/lib/public_api.ts index bf44dbe51ea0..19d003d3d1c6 100644 --- a/src/lib/public_api.ts +++ b/src/lib/public_api.ts @@ -39,6 +39,7 @@ export * from './sidenav/index'; export * from './slider/index'; export * from './slide-toggle/index'; export * from './snack-bar/index'; +export * from './sort/index'; export * from './tabs/index'; export * from './tabs/tab-nav-bar/index'; export * from './toolbar/index'; diff --git a/src/lib/sort/index.ts b/src/lib/sort/index.ts new file mode 100644 index 000000000000..7d1ed5454b68 --- /dev/null +++ b/src/lib/sort/index.ts @@ -0,0 +1,26 @@ +/** + * @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 {MdSortHeader} from './sort-header'; +import {MdSort} from './sort'; +import {MdSortHeaderIntl} from './sort-header-intl'; +import {CommonModule} from '@angular/common'; + +export * from './sort-direction'; +export * from './sort-header'; +export * from './sort-header-intl'; +export * from './sort'; + +@NgModule({ + imports: [CommonModule], + exports: [MdSort, MdSortHeader], + declarations: [MdSort, MdSortHeader], + providers: [MdSortHeaderIntl] +}) +export class MdSortModule {} diff --git a/src/lib/sort/sort-direction.ts b/src/lib/sort/sort-direction.ts new file mode 100644 index 000000000000..9a1f2657efa6 --- /dev/null +++ b/src/lib/sort/sort-direction.ts @@ -0,0 +1,9 @@ +/** + * @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 + */ + +export type SortDirection = 'asc' | 'desc' | ''; diff --git a/src/lib/sort/sort-errors.ts b/src/lib/sort/sort-errors.ts new file mode 100644 index 000000000000..f9eb38da6204 --- /dev/null +++ b/src/lib/sort/sort-errors.ts @@ -0,0 +1,22 @@ +/** + * @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 + */ + +/** @docs-private */ +export function getMdSortDuplicateMdSortableIdError(id: string): Error { + return Error(`Cannot have two MdSortables with the same id (${id}).`); +} + +/** @docs-private */ +export function getMdSortHeaderNotContainedWithinMdSortError(): Error { + return Error(`MdSortHeader must be placed within a parent element with the MdSort directive.`); +} + +/** @docs-private */ +export function getMdSortHeaderMissingIdError(): Error { + return Error(`MdSortHeader must be provided with a unique id.`); +} diff --git a/src/lib/sort/sort-header-intl.ts b/src/lib/sort/sort-header-intl.ts new file mode 100644 index 000000000000..a15734ecb94b --- /dev/null +++ b/src/lib/sort/sort-header-intl.ts @@ -0,0 +1,26 @@ +/** + * @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'; +import {SortDirection} from './sort-direction'; + +/** + * To modify the labels and text displayed, create a new instance of MdSortHeaderIntl and + * include it in a custom provider. + */ +@Injectable() +export class MdSortHeaderIntl { + sortButtonLabel = (id: string) => { + return `Change sorting for ${id}`; + } + + /** A label to describe the current sort (visible only to screenreaders). */ + sortDescriptionLabel = (id: string, direction: SortDirection) => { + return `Sorted by ${id} ${direction == 'asc' ? 'ascending' : 'descending'}`; + } +} diff --git a/src/lib/sort/sort-header.html b/src/lib/sort/sort-header.html new file mode 100644 index 000000000000..d70be440d20d --- /dev/null +++ b/src/lib/sort/sort-header.html @@ -0,0 +1,20 @@ +
+ + +
+
+
+
+
+
+ + + {{_intl.sortDescriptionLabel(id, _sort.direction)}} + diff --git a/src/lib/sort/sort-header.scss b/src/lib/sort/sort-header.scss new file mode 100644 index 000000000000..64c3dbf67d28 --- /dev/null +++ b/src/lib/sort/sort-header.scss @@ -0,0 +1,74 @@ +$mat-sort-header-arrow-margin: 6px; +$mat-sort-header-arrow-container-size: 10px; +$mat-sort-header-arrow-pointer-length: 8px; +$mat-sort-header-arrow-thickness: 2px; + +.mat-sort-header-container { + display: flex; + cursor: pointer; +} + +.mat-sort-header-position-before { + flex-direction: row-reverse; +} + +.mat-sort-header-button { + border: none; + background: 0 0; + display: flex; + align-items: center; + padding: 0; + cursor: pointer; + outline: 0; + font: inherit; + color: currentColor; +} + +.mat-sort-header-arrow { + display: none; + height: $mat-sort-header-arrow-container-size; + width: $mat-sort-header-arrow-container-size; + position: relative; + margin: 0 0 0 $mat-sort-header-arrow-margin; + + .mat-sort-header-position-before & { + margin: 0 $mat-sort-header-arrow-margin 0 0; + } +} + +.mat-sort-header-asc { + display: block; + transform: rotate(45deg); +} + +.mat-sort-header-desc { + display: block; + transform: rotate(225deg); + top: $mat-sort-header-arrow-thickness; +} + +.mat-sort-header-stem { + background: currentColor; + transform: rotate(135deg); + height: $mat-sort-header-arrow-container-size; + width: $mat-sort-header-arrow-thickness; + margin: auto; +} + +.mat-sort-header-pointer-left { + background: currentColor; + width: $mat-sort-header-arrow-thickness; + height: $mat-sort-header-arrow-pointer-length; + position: absolute; + bottom: 0; + right: 0; +} + +.mat-sort-header-pointer-right { + background: currentColor; + width: $mat-sort-header-arrow-pointer-length; + height: $mat-sort-header-arrow-thickness; + position: absolute; + bottom: 0; + right: 0; +} diff --git a/src/lib/sort/sort-header.ts b/src/lib/sort/sort-header.ts new file mode 100644 index 000000000000..c0532587c286 --- /dev/null +++ b/src/lib/sort/sort-header.ts @@ -0,0 +1,94 @@ +/** + * @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, ChangeDetectorRef, Component, Input, + Optional, ViewEncapsulation +} from '@angular/core'; +import {MdSort, MdSortable} from './sort'; +import {MdSortHeaderIntl} from './sort-header-intl'; +import {CdkColumnDef} from '../core/data-table/cell'; +import {coerceBooleanProperty} from '../core'; +import {getMdSortHeaderNotContainedWithinMdSortError} from './sort-errors'; +import {Subscription} from 'rxjs/Subscription'; + +/** + * Applies sorting behavior (click to change sort) and styles to an element, including an + * arrow to display the current sort direction. + * + * Must be provided with an id and contained within a parent MdSort directive. + * + * If used on header cells in a CdkTable, it will automatically default its id from its containing + * column definition. + */ +@Component({ + moduleId: module.id, + selector: '[md-sort-header], [mat-sort-header]', + templateUrl: 'sort-header.html', + styleUrls: ['sort-header.css'], + host: { + '(click)': '_sort.sort(this)', + '[class.mat-sort-header-sorted]': '_isSorted()', + }, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MdSortHeader implements MdSortable { + sortSubscription: Subscription; + + /** + * ID of this sort header. If used within the context of a CdkColumnDef, this will default to + * the column's name. + */ + @Input('md-sort-header') id: string; + + /** Sets the position of the arrow that displays when sorted. */ + @Input() arrowPosition: 'before' | 'after' = 'after'; + + /** Overrides the sort start value of the containing MdSort for this MdSortable. */ + @Input('start') start: 'asc' | 'desc'; + + /** Overrides the disable clear value of the containing MdSort for this MdSortable. */ + @Input() + get disableClear() { return this._disableClear; } + set disableClear(v) { this._disableClear = coerceBooleanProperty(v); } + private _disableClear: boolean; + + @Input('mat-sort-header') + get _id() { return this.id; } + set _id(v: string) { this.id = v; } + + constructor(public _intl: MdSortHeaderIntl, + private _changeDetectorRef: ChangeDetectorRef, + @Optional() public _sort: MdSort, + @Optional() public _cdkColumnDef: CdkColumnDef) { + if (!_sort) { + throw getMdSortHeaderNotContainedWithinMdSortError(); + } + + this.sortSubscription = _sort.mdSortChange.subscribe(() => _changeDetectorRef.markForCheck()); + } + + ngOnInit() { + if (!this.id && this._cdkColumnDef) { + this.id = this._cdkColumnDef.name; + } + + this._sort.register(this); + } + + ngOnDestroy() { + this._sort.deregister(this); + this.sortSubscription.unsubscribe(); + } + + /** Whether this MdSortHeader is currently sorted in either ascending or descending order. */ + _isSorted() { + return this._sort.active == this.id && this._sort.direction; + } +} diff --git a/src/lib/sort/sort.spec.ts b/src/lib/sort/sort.spec.ts new file mode 100644 index 000000000000..dadc2fe7422b --- /dev/null +++ b/src/lib/sort/sort.spec.ts @@ -0,0 +1,263 @@ +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {Component, ElementRef, ViewChild} from '@angular/core'; +import {MdSort, MdSortHeader, Sort, SortDirection, MdSortModule} from './index'; +import {CdkDataTableModule, DataSource, CollectionViewer} from '../core/data-table/index'; +import {Observable} from 'rxjs/Observable'; +import {dispatchMouseEvent} from '../core/testing/dispatch-events'; +import { + getMdSortDuplicateMdSortableIdError, + getMdSortHeaderMissingIdError, + getMdSortHeaderNotContainedWithinMdSortError +} from './sort-errors'; +import {wrappedErrorMessage} from '../core/testing/wrapped-error-message'; +import {map} from '../core/rxjs/index'; + +describe('MdSort', () => { + let fixture: ComponentFixture; + + let component: SimpleMdSortApp; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MdSortModule, CdkDataTableModule], + declarations: [ + SimpleMdSortApp, + CdkTableMdSortApp, + MdSortHeaderMissingMdSortApp, + MdSortDuplicateMdSortableIdsApp, + MdSortableMissingIdApp + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleMdSortApp); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should have the sort headers register and deregister themselves', () => { + const sortables = component.mdSort.sortables; + expect(sortables.size).toBe(4); + expect(sortables.get('defaultSortHeaderA')).toBe(component.mdSortHeaderDefaultA); + expect(sortables.get('defaultSortHeaderB')).toBe(component.mdSortHeaderDefaultB); + + fixture.destroy(); + expect(sortables.size).toBe(0); + }); + + it('should use the column definition if used within a cdk table', () => { + let cdkTableMdSortAppFixture = TestBed.createComponent(CdkTableMdSortApp); + + let cdkTableMdSortAppComponent = cdkTableMdSortAppFixture.componentInstance; + + cdkTableMdSortAppFixture.detectChanges(); + cdkTableMdSortAppFixture.detectChanges(); + + const sortables = cdkTableMdSortAppComponent.mdSort.sortables; + expect(sortables.size).toBe(3); + expect(sortables.has('column_a')).toBe(true); + expect(sortables.has('column_b')).toBe(true); + expect(sortables.has('column_c')).toBe(true); + }); + + it('should be able to cycle from asc -> desc from either start point', () => { + component.disableClear = true; + + component.start = 'asc'; + testSingleColumnSortDirectionSequence(fixture, ['asc', 'desc']); + + // Reverse directions + component.start = 'desc'; + testSingleColumnSortDirectionSequence(fixture, ['desc', 'asc']); + }); + + it('should be able to cycle asc -> desc -> [none]', () => { + component.start = 'asc'; + testSingleColumnSortDirectionSequence(fixture, ['asc', 'desc', '']); + }); + + it('should be able to cycle desc -> asc -> [none]', () => { + component.start = 'desc'; + testSingleColumnSortDirectionSequence(fixture, ['desc', 'asc', '']); + }); + + it('should reset sort direction when a different column is sorted', () => { + component.sort('defaultSortHeaderA'); + expect(component.mdSort.active).toBe('defaultSortHeaderA'); + expect(component.mdSort.direction).toBe('asc'); + + component.sort('defaultSortHeaderA'); + expect(component.mdSort.active).toBe('defaultSortHeaderA'); + expect(component.mdSort.direction).toBe('desc'); + + component.sort('defaultSortHeaderB'); + expect(component.mdSort.active).toBe('defaultSortHeaderB'); + expect(component.mdSort.direction).toBe('asc'); + }); + + it('should throw an error if an MdSortable is not contained within an MdSort directive', () => { + expect(() => TestBed.createComponent(MdSortHeaderMissingMdSortApp).detectChanges()) + .toThrowError(wrappedErrorMessage(getMdSortHeaderNotContainedWithinMdSortError())); + }); + + it('should throw an error if two MdSortables have the same id', () => { + expect(() => TestBed.createComponent(MdSortDuplicateMdSortableIdsApp).detectChanges()) + .toThrowError(wrappedErrorMessage(getMdSortDuplicateMdSortableIdError('duplicateId'))); + }); + + it('should throw an error if an MdSortable is missing an id', () => { + expect(() => TestBed.createComponent(MdSortableMissingIdApp).detectChanges()) + .toThrowError(wrappedErrorMessage(getMdSortHeaderMissingIdError())); + }); + + it('should allow let MdSortable override the default sort parameters', () => { + testSingleColumnSortDirectionSequence( + fixture, ['asc', 'desc', '']); + + testSingleColumnSortDirectionSequence( + fixture, ['desc', 'asc', ''], 'overrideStart'); + + testSingleColumnSortDirectionSequence( + fixture, ['asc', 'desc'], 'overrideDisableClear'); + }); + + it('should apply the aria-labels to the button', () => { + const button = fixture.nativeElement.querySelector('#defaultSortHeaderA button'); + expect(button.getAttribute('aria-label')).toBe('Change sorting for defaultSortHeaderA'); + }); +}); + +/** + * Performs a sequence of sorting on a single column to see if the sort directions are + * consistent with expectations. Detects any changes in the fixture to reflect any changes in + * the inputs and resets the MdSort to remove any side effects from previous tests. + */ +function testSingleColumnSortDirectionSequence(fixture: ComponentFixture, + expectedSequence: SortDirection[], + id: string = 'defaultSortHeaderA') { + // Detect any changes that were made in preparation for this sort sequence + fixture.detectChanges(); + + // Reset the md sort to make sure there are no side affects from previous tests + const component = fixture.componentInstance; + component.mdSort.active = ''; + component.mdSort.direction = ''; + + // Run through the sequence to confirm the order + let actualSequence = expectedSequence.map(() => { + component.sort(id); + + // Check that the sort event's active sort is consistent with the MdSort + expect(component.mdSort.active).toBe(id); + expect(component.latestSortEvent.active).toBe(id); + + // Check that the sort event's direction is consistent with the MdSort + expect(component.mdSort.direction).toBe(component.latestSortEvent.direction); + return component.mdSort.direction; + }); + expect(actualSequence).toEqual(expectedSequence); + + // Expect that performing one more sort will loop it back to the beginning. + component.sort(id); + expect(component.mdSort.direction).toBe(expectedSequence[0]); +} + +@Component({ + template: ` +
+
A
+
B
+
D
+
E
+
+ ` +}) +class SimpleMdSortApp { + latestSortEvent: Sort; + + active: string; + start: SortDirection = 'asc'; + direction: SortDirection = ''; + disableClear: boolean; + + @ViewChild(MdSort) mdSort: MdSort; + @ViewChild('defaultSortHeaderA') mdSortHeaderDefaultA: MdSortHeader; + @ViewChild('defaultSortHeaderB') mdSortHeaderDefaultB: MdSortHeader; + + constructor (public elementRef: ElementRef) { } + + sort(id: string) { + const sortElement = this.elementRef.nativeElement.querySelector(`#${id}`); + dispatchMouseEvent(sortElement, 'click'); + } +} + + +class FakeDataSource extends DataSource { + connect(collectionViewer: CollectionViewer): Observable { + return map.call(collectionViewer.viewChange, () => []); + } +} + +@Component({ + template: ` + + + Column A + {{row.a}} + + + + Column B + {{row.b}} + + + + Column C + {{row.c}} + + + + + + ` +}) +class CdkTableMdSortApp { + @ViewChild(MdSort) mdSort: MdSort; + + dataSource = new FakeDataSource(); + columnsToRender = ['column_a', 'column_b', 'column_c']; +} + + +@Component({ + template: `
A
` +}) +class MdSortHeaderMissingMdSortApp { } + + +@Component({ + template: ` +
+
A
+
A
+
+ ` +}) +class MdSortDuplicateMdSortableIdsApp { } + + +@Component({ + template: ` +
+
A
+
+ ` +}) +class MdSortableMissingIdApp { } diff --git a/src/lib/sort/sort.ts b/src/lib/sort/sort.ts new file mode 100644 index 000000000000..c28841f2b596 --- /dev/null +++ b/src/lib/sort/sort.ts @@ -0,0 +1,115 @@ +/** + * @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 {Directive, EventEmitter, Input, Output} from '@angular/core'; +import {SortDirection} from './sort-direction'; +import {coerceBooleanProperty} from '../core'; +import {getMdSortDuplicateMdSortableIdError, getMdSortHeaderMissingIdError} from './sort-errors'; + +export interface MdSortable { + id: string; + start: 'asc' | 'desc'; + disableClear: boolean; +} + +export interface Sort { + active: string; + direction: SortDirection; +} + +/** Container for MdSortables to manage the sort state and provide default sort parameters. */ +@Directive({ + selector: '[mdSort], [matSort]', +}) +export class MdSort { + /** Collection of all registered sortables that this directive manages. */ + sortables = new Map(); + + /** The id of the most recently sorted MdSortable. */ + @Input('mdSortActive') active: string; + + /** + * The direction to set when an MdSortable is initially sorted. + * May be overriden by the MdSortable's sort start. + */ + @Input('mdSortStart') start: 'asc' | 'desc' = 'asc'; + + /** The sort direction of the currently active MdSortable. */ + @Input('mdSortDirection') direction: SortDirection = ''; + + /** + * Whether to disable the user from clearing the sort by finishing the sort direction cycle. + * May be overriden by the MdSortable's disable clear input. + */ + @Input('mdSortDisableClear') + get disableClear() { return this._disableClear; } + set disableClear(v) { this._disableClear = coerceBooleanProperty(v); } + private _disableClear: boolean; + + /** Event emitted when the user changes either the active sort or sort direction. */ + @Output() mdSortChange = new EventEmitter(); + + /** + * Register function to be used by the contained MdSortables. Adds the MdSortable to the + * collection of MdSortables. + */ + register(sortable: MdSortable) { + if (!sortable.id) { + throw getMdSortHeaderMissingIdError(); + } + + if (this.sortables.has(sortable.id)) { + throw getMdSortDuplicateMdSortableIdError(sortable.id); + } + this.sortables.set(sortable.id, sortable); + } + + /** + * Unregister function to be used by the contained MdSortables. Removes the MdSortable from the + * collection of contained MdSortables. + */ + deregister(sortable: MdSortable) { + this.sortables.delete(sortable.id); + } + + /** Sets the active sort id and determines the new sort direction. */ + sort(sortable: MdSortable) { + if (this.active != sortable.id) { + this.active = sortable.id; + this.direction = sortable.start ? sortable.start : this.start; + } else { + this.direction = this.getNextSortDirection(sortable); + } + + this.mdSortChange.next({active: this.active, direction: this.direction}); + } + + /** Returns the next sort direction of the active sortable, checking for potential overrides. */ + getNextSortDirection(sortable: MdSortable): SortDirection { + if (!sortable) { return ''; } + + // Get the sort direction cycle with the potential sortable overrides. + const disableClear = sortable.disableClear != null ? sortable.disableClear : this.disableClear; + let sortDirectionCycle = getSortDirectionCycle(sortable.start || this.start, disableClear); + + // Get and return the next direction in the cycle + let nextDirectionIndex = sortDirectionCycle.indexOf(this.direction) + 1; + if (nextDirectionIndex >= sortDirectionCycle.length) { nextDirectionIndex = 0; } + return sortDirectionCycle[nextDirectionIndex]; + } +} + +/** Returns the sort direction cycle to use given the provided parameters of order and clear. */ +function getSortDirectionCycle(start: 'asc' | 'desc', + disableClear: boolean): SortDirection[] { + let sortOrder: SortDirection[] = ['asc', 'desc']; + if (start == 'desc') { sortOrder.reverse(); } + if (!disableClear) { sortOrder.push(''); } + + return sortOrder; +}