diff --git a/src/components-examples/material/table/table-sorting/table-sorting-example.html b/src/components-examples/material/table/table-sorting/table-sorting-example.html index 76edcae012cb..3345a7ba5b52 100644 --- a/src/components-examples/material/table/table-sorting/table-sorting-example.html +++ b/src/components-examples/material/table/table-sorting/table-sorting-example.html @@ -1,36 +1,50 @@ + + Single-sorting + Multi-sorting + + - - - - + + + + + + + - - - - + - - - - + - - - - + diff --git a/src/components-examples/material/table/table-sorting/table-sorting-example.ts b/src/components-examples/material/table/table-sorting/table-sorting-example.ts index f88e6a59958a..c736df045a09 100644 --- a/src/components-examples/material/table/table-sorting/table-sorting-example.ts +++ b/src/components-examples/material/table/table-sorting/table-sorting-example.ts @@ -1,25 +1,106 @@ import {LiveAnnouncer} from '@angular/cdk/a11y'; import {AfterViewInit, Component, ViewChild, inject} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {MatButtonToggleModule} from '@angular/material/button-toggle'; import {MatSort, Sort, MatSortModule} from '@angular/material/sort'; import {MatTableDataSource, MatTableModule} from '@angular/material/table'; -export interface PeriodicElement { - name: string; - position: number; - weight: number; - symbol: string; +export interface EmployeeData { + firstName: string; + lastName: string; + position: string; + office: string; + salary: number; } -const ELEMENT_DATA: PeriodicElement[] = [ - {position: 1, name: 'Hydrogen', weight: 1.0079, symbol: 'H'}, - {position: 2, name: 'Helium', weight: 4.0026, symbol: 'He'}, - {position: 3, name: 'Lithium', weight: 6.941, symbol: 'Li'}, - {position: 4, name: 'Beryllium', weight: 9.0122, symbol: 'Be'}, - {position: 5, name: 'Boron', weight: 10.811, symbol: 'B'}, - {position: 6, name: 'Carbon', weight: 12.0107, symbol: 'C'}, - {position: 7, name: 'Nitrogen', weight: 14.0067, symbol: 'N'}, - {position: 8, name: 'Oxygen', weight: 15.9994, symbol: 'O'}, - {position: 9, name: 'Fluorine', weight: 18.9984, symbol: 'F'}, - {position: 10, name: 'Neon', weight: 20.1797, symbol: 'Ne'}, + +const EMPLOYEE_DATA: EmployeeData[] = [ + { + firstName: 'Garrett', + lastName: 'Winters', + position: 'Accountant', + office: 'Tokyo', + salary: 170750, + }, + {firstName: 'Airi', lastName: 'Satou', position: 'Accountant', office: 'Tokyo', salary: 162700}, + { + firstName: 'Donna', + lastName: 'Snider', + position: 'Customer Support', + office: 'New York', + salary: 112000, + }, + { + firstName: 'Serge', + lastName: 'Baldwin', + position: 'Data Coordinator', + office: 'Singapore', + salary: 138575, + }, + {firstName: 'Thor', lastName: 'Walton', position: 'Developer', office: 'New York', salary: 98540}, + { + firstName: 'Gavin', + lastName: 'Joyce', + position: 'Developer', + office: 'Edinburgh', + salary: 92575, + }, + {firstName: 'Suki', lastName: 'Burks', position: 'Developer', office: 'London', salary: 114500}, + { + firstName: 'Jonas', + lastName: 'Alexander', + position: 'Developer', + office: 'San Francisco', + salary: 86500, + }, + { + firstName: 'Jackson', + lastName: 'Bradshaw', + position: 'Director', + office: 'New York', + salary: 645750, + }, + { + firstName: 'Brielle', + lastName: 'Williamson', + position: 'Integration Specialist', + office: 'New York', + salary: 372000, + }, + { + firstName: 'Michelle', + lastName: 'House', + position: 'Integration Specialist', + office: 'Sydney', + salary: 95400, + }, + { + firstName: 'Michael', + lastName: 'Bruce', + position: 'Javascript Developer', + office: 'Singapore', + salary: 183000, + }, + { + firstName: 'Ashton', + lastName: 'Cox', + position: 'Junior Technical Author', + office: 'San Francisco', + salary: 86000, + }, + { + firstName: 'Michael', + lastName: 'Silva', + position: 'Marketing Designer', + office: 'London', + salary: 198500, + }, + { + firstName: 'Timothy', + lastName: 'Mooney', + position: 'Office Manager', + office: 'London', + salary: 136200, + }, ]; /** * @title Table with sorting @@ -28,13 +109,14 @@ const ELEMENT_DATA: PeriodicElement[] = [ selector: 'table-sorting-example', styleUrl: 'table-sorting-example.css', templateUrl: 'table-sorting-example.html', - imports: [MatTableModule, MatSortModule], + imports: [MatTableModule, MatSortModule, FormsModule, MatButtonToggleModule], }) export class TableSortingExample implements AfterViewInit { private _liveAnnouncer = inject(LiveAnnouncer); - displayedColumns: string[] = ['position', 'name', 'weight', 'symbol']; - dataSource = new MatTableDataSource(ELEMENT_DATA); + multiSortEnabled = false; + displayedColumns: string[] = ['firstName', 'lastName', 'position', 'office', 'salary']; + dataSource = new MatTableDataSource(EMPLOYEE_DATA); @ViewChild(MatSort) sort: MatSort; diff --git a/src/material/sort/sort-header.ts b/src/material/sort/sort-header.ts index 51d529b0fcbd..73c1a60e0d28 100644 --- a/src/material/sort/sort-header.ts +++ b/src/material/sort/sort-header.ts @@ -223,12 +223,41 @@ export class MatSortHeader implements MatSortable, OnDestroy, OnInit, AfterViewI /** Whether this MatSortHeader is currently sorted in either ascending or descending order. */ _isSorted() { + const currentSortDirection = this._sort.getCurrentSortDirection(this.id); + return ( - this._sort.active == this.id && - (this._sort.direction === 'asc' || this._sort.direction === 'desc') + this._sort.isActive(this.id) && + (currentSortDirection === 'asc' || currentSortDirection === 'desc') ); } + /** Returns the animation state for the arrow direction (indicator and pointers). */ + _getArrowDirectionState() { + return `${this._isSorted() ? 'active-' : ''}${this._arrowDirection}`; + } + + /** Returns the arrow position state (opacity, translation). */ + _getArrowViewState() { + const fromState = this._viewState.fromState; + return (fromState ? `${fromState}-to-` : '') + this._viewState.toState; + } + + /** + * Updates the direction the arrow should be pointing. If it is not sorted, the arrow should be + * facing the start direction. Otherwise if it is sorted, the arrow should point in the currently + * active sorted direction. The reason this is updated through a function is because the direction + * should only be changed at specific times - when deactivated but the hint is displayed and when + * the sort is active and the direction changes. Otherwise the arrow's direction should linger + * in cases such as the sort becoming deactivated but we want to animate the arrow away while + * preserving its direction, even though the next sort direction is actually different and should + * only be changed once the arrow displays again (hint or activation). + */ + _updateArrowDirection() { + this._arrowDirection = this._isSorted() + ? this._sort.getCurrentSortDirection(this.id) + : this.start || this._sort.start; + } + _isDisabled() { return this._sort.disabled || this.disabled; } @@ -244,7 +273,7 @@ export class MatSortHeader implements MatSortable, OnDestroy, OnInit, AfterViewI return 'none'; } - return this._sort.direction == 'asc' ? 'ascending' : 'descending'; + return this._sort.getCurrentSortDirection(this.id) == 'asc' ? 'ascending' : 'descending'; } /** Whether the arrow inside the sort header should be rendered. */ diff --git a/src/material/sort/sort.md b/src/material/sort/sort.md index 68cc4ff2c3f8..19bff2fdbaf3 100644 --- a/src/material/sort/sort.md +++ b/src/material/sort/sort.md @@ -27,6 +27,14 @@ To prevent the user from clearing the sort state from an already sorted column, `matSortDisableClear` to `true` on the `matSort` to affect all headers, or set `disableClear` to `true` on a specific header. +#### Enabling multi-sort + +By default the sorting behavior only accepts sorting by a single column. In order to change that and have multi-column sorting, set the `matSortMultiple` on the `matSort` directive. + +When using multi-sorting, there's no changes to the `matSortChange` events to avoid breaking backwards compatibility. If you need to get the current sortState containing all sorted columns, you need to access the `matTable.sortState` field directly. + +> Notice that the order on which the columns are sorted does matter. + #### Disabling sorting If you want to prevent the user from changing the sorting order of any column, you can use the diff --git a/src/material/sort/sort.spec.ts b/src/material/sort/sort.spec.ts index 55d21297694e..1083a7e73cae 100644 --- a/src/material/sort/sort.spec.ts +++ b/src/material/sort/sort.spec.ts @@ -52,6 +52,9 @@ describe('MatSort', () => { fixture = TestBed.createComponent(SimpleMatSortApp); component = fixture.componentInstance; fixture.detectChanges(); + + component.matSort.matSortMultiple = false; + component.matSort.sortState.clear(); }); it('should have the sort headers register and deregister themselves', () => { @@ -293,6 +296,23 @@ describe('MatSort', () => { expect(descriptionElement?.textContent).toBe('Sort 2nd column'); }); + it('should be able to store sorting for multiple columns when using multisort', () => { + component.matSort.matSortMultiple = true; + component.start = 'asc'; + testSingleColumnSortDirectionSequence(fixture, ['asc', 'desc', ''], 'defaultA'); + testSingleColumnSortDirectionSequence(fixture, ['asc', 'desc', ''], 'defaultB'); + + expect(component.matSort.sortState.size).toBe(2); + + const defaultAState = component.matSort.sortState.get('defaultA'); + expect(defaultAState).toBeTruthy(); + expect(defaultAState?.direction).toBe(component.start); + + const defaultBState = component.matSort.sortState.get('defaultB'); + expect(defaultBState).toBeTruthy(); + expect(defaultBState?.direction).toBe(component.start); + }); + it('should render arrows after sort header by default', () => { const matSortWithArrowPositionFixture = TestBed.createComponent(MatSortWithArrowPosition); diff --git a/src/material/sort/sort.ts b/src/material/sort/sort.ts index b29325dbb65f..5d4430f6caa7 100644 --- a/src/material/sort/sort.ts +++ b/src/material/sort/sort.ts @@ -17,6 +17,7 @@ import { OnInit, Optional, Output, + SimpleChanges, booleanAttribute, } from '@angular/core'; import {Observable, ReplaySubject, Subject} from 'rxjs'; @@ -26,6 +27,7 @@ import { getSortHeaderMissingIdError, getSortInvalidDirectionError, } from './sort-errors'; +import {coerceBooleanProperty} from '@angular/cdk/coercion'; /** Position of the arrow that displays when sorted. */ export type SortHeaderArrowPosition = 'before' | 'after'; @@ -78,6 +80,9 @@ export class MatSort implements OnChanges, OnDestroy, OnInit { /** Collection of all registered sortables that this directive manages. */ sortables = new Map(); + /** Map of sort state for each column */ + sortState = new Map(); + /** Used to notify any child components listening to state changes. */ readonly _stateChanges = new Subject(); @@ -108,6 +113,16 @@ export class MatSort implements OnChanges, OnDestroy, OnInit { } private _direction: SortDirection = ''; + /** Whether to enable the multi-sorting feature or not */ + @Input('matSortMultiple') + get matSortMultiple(): boolean { + return this._sortMultiple; + } + set matSortMultiple(value: any) { + this._sortMultiple = coerceBooleanProperty(value); + } + private _sortMultiple = false; + /** * Whether to disable the user from clearing the sort by finishing the sort direction cycle. * May be overridden by the MatSortable's disable clear input. @@ -159,29 +174,65 @@ export class MatSort implements OnChanges, OnDestroy, OnInit { /** Sets the active sort id and determines the new sort direction. */ sort(sortable: MatSortable): void { + let sortableDirection; + if (!this.isActive(sortable.id)) { + sortableDirection = sortable.start ?? this.start; + } else { + sortableDirection = this.getNextSortDirection(sortable); + } + + // Avoid keeping multiple sorts if not required; + if (!this._sortMultiple) { + this.sortState.clear(); + } + + // Update active and direction to keep backwards compatibility if (this.active != sortable.id) { this.active = sortable.id; - this.direction = sortable.start ? sortable.start : this.start; + } + + this.direction = sortableDirection; + + const currentSort: Sort = { + active: sortable.id, + direction: sortableDirection, + }; + + // When unsorted, remove from state + if (sortableDirection !== '') { + this.sortState.set(sortable.id, currentSort); } else { - this.direction = this.getNextSortDirection(sortable); + this.sortState.delete(sortable.id); } this.sortChange.emit({active: this.active, direction: this.direction}); } + /** Checks whether the provided column is currently active (has been sorted). */ + isActive(id: string): boolean { + return this.sortState.has(id); + } + + /** Returns the current SortDirection of the supplied column id, defaults to unsorted if no state is found. */ + getCurrentSortDirection(id: string): SortDirection { + return this.sortState.get(id)?.direction ?? this.sortables.get(id)?.start ?? this.start; + } + /** Returns the next sort direction of the active sortable, checking for potential overrides. */ getNextSortDirection(sortable: MatSortable): SortDirection { if (!sortable) { return ''; } + const currentSortableDirection = this.getCurrentSortDirection(sortable.id); + // Get the sort direction cycle with the potential sortable overrides. const disableClear = sortable?.disableClear ?? this.disableClear ?? !!this._defaultOptions?.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; + let nextDirectionIndex = sortDirectionCycle.indexOf(currentSortableDirection) + 1; if (nextDirectionIndex >= sortDirectionCycle.length) { nextDirectionIndex = 0; } @@ -192,7 +243,22 @@ export class MatSort implements OnChanges, OnDestroy, OnInit { this._initializedStream.next(); } - ngOnChanges() { + ngOnChanges(changes: SimpleChanges) { + /** Update sortState with active and direction values, otherwise sorting won't work */ + if (changes['active'] || changes['direction']) { + const currentActive = changes['active']?.currentValue ?? this.active; + const currentDirection = changes['active']?.currentValue ?? this.direction ?? this.start; + + if ((!currentActive || currentActive == '') && changes['active']?.previousValue) { + this.sortState.delete(changes['active'].previousValue); + } else { + this.sortState.set(currentActive, { + active: currentActive, + direction: currentDirection, + }); + } + } + this._stateChanges.next(); } diff --git a/src/material/table/table-data-source.ts b/src/material/table/table-data-source.ts index a5bcb8fc81b6..b0df140b0178 100644 --- a/src/material/table/table-data-source.ts +++ b/src/material/table/table-data-source.ts @@ -171,50 +171,63 @@ export class MatTableDataSource extend * @param sort The connected MatSort that holds the current sort state. */ sortData: (data: T[], sort: MatSort) => T[] = (data: T[], sort: MatSort): T[] => { - const active = sort.active; - const direction = sort.direction; - if (!active || direction == '') { + const sortState = Array.from(sort.sortState.values()); + + if (!sortState.length) { return data; } return data.sort((a, b) => { - let valueA = this.sortingDataAccessor(a, active); - let valueB = this.sortingDataAccessor(b, active); - - // If there are data in the column that can be converted to a number, - // it must be ensured that the rest of the data - // is of the same type so as not to order incorrectly. - const valueAType = typeof valueA; - const valueBType = typeof valueB; - - if (valueAType !== valueBType) { - if (valueAType === 'number') { - valueA += ''; - } - if (valueBType === 'number') { - valueB += ''; - } - } - - // If both valueA and valueB exist (truthy), then compare the two. Otherwise, check if - // one value exists while the other doesn't. In this case, existing value should come last. - // This avoids inconsistent results when comparing values to undefined/null. - // If neither value exists, return 0 (equal). - let comparatorResult = 0; - if (valueA != null && valueB != null) { - // Check if one value is greater than the other; if equal, comparatorResult should remain 0. - if (valueA > valueB) { - comparatorResult = 1; - } else if (valueA < valueB) { - comparatorResult = -1; - } - } else if (valueA != null) { - comparatorResult = 1; - } else if (valueB != null) { - comparatorResult = -1; - } - - return comparatorResult * (direction == 'asc' ? 1 : -1); + return ( + sortState + // skip unsorted columns + .filter(it => it.direction !== '') + + // Apply sorting to each 'sorted' column, consider next column only if previous sort == 0 + .reduce((previous, state) => { + if (previous !== 0) { + return previous; + } + + let valueA = this.sortingDataAccessor(a, state.active); + let valueB = this.sortingDataAccessor(b, state.active); + + // If there are data in the column that can be converted to a number, + // it must be ensured that the rest of the data + // is of the same type so as not to order incorrectly. + const valueAType = typeof valueA; + const valueBType = typeof valueB; + + if (valueAType !== valueBType) { + if (valueAType === 'number') { + valueA += ''; + } + if (valueBType === 'number') { + valueB += ''; + } + } + + // If both valueA and valueB exist (truthy), then compare the two. Otherwise, check if + // one value exists while the other doesn't. In this case, existing value should come last. + // This avoids inconsistent results when comparing values to undefined/null. + // If neither value exists, return 0 (equal). + let comparatorResult = 0; + if (valueA != null && valueB != null) { + // Check if one value is greater than the other; if equal, comparatorResult should remain 0. + if (valueA > valueB) { + comparatorResult = 1; + } else if (valueA < valueB) { + comparatorResult = -1; + } + } else if (valueA != null) { + comparatorResult = 1; + } else if (valueB != null) { + comparatorResult = -1; + } + + return comparatorResult * (state.direction === 'asc' ? 1 : -1); + }, 0) + ); }); }; diff --git a/src/material/table/table.spec.ts b/src/material/table/table.spec.ts index 2dbc82426740..bbe710919454 100644 --- a/src/material/table/table.spec.ts +++ b/src/material/table/table.spec.ts @@ -194,6 +194,20 @@ describe('MatTable', () => { ]); }); + it('should render with MatTableDataSource and multi-sort', () => { + let fixture = TestBed.createComponent(MatTableWithMultiSortApp); + fixture.detectChanges(); + + const tableElement = fixture.nativeElement.querySelector('table'); + const data = fixture.componentInstance.dataSource!.data; + expectTableToMatchContent(tableElement, [ + ['Column A', 'Column B', 'Column C'], + [data[0].a, data[0].b, data[0].c], + [data[1].a, data[1].b, data[1].c], + [data[2].a, data[2].b, data[2].c], + ]); + }); + it('should render with MatTableDataSource and pagination', () => { let fixture = TestBed.createComponent(MatTableWithPaginatorApp); fixture.detectChanges(); @@ -247,6 +261,9 @@ describe('MatTable', () => { tableElement = fixture.nativeElement.querySelector('table'); component = fixture.componentInstance; dataSource = fixture.componentInstance.dataSource; + + component.sort.matSortMultiple = false; + component.sort.sortState.clear(); }); it('should create table and display data source contents', () => { @@ -444,7 +461,7 @@ describe('MatTable', () => { return ''; } }; - component.sort.direction = ''; + component.sort.sortState.clear(); component.sort.sort(component.sortHeader); expectTableToMatchContent(tableElement, [ ['Column A', 'Column B', 'Column C'], @@ -975,6 +992,57 @@ class MatTableWithSortApp implements OnInit { } } +@Component({ + template: ` +
- No. + + + + First name {{element.position}} {{element.firstName}} + Last name + {{element.lastName}} - Name + + + + Position {{element.name}} {{element.position}} - Weight + + + + Office {{element.weight}} {{element.office}} - Symbol + + + + Salary {{element.symbol}} {{element.salary}}
+ + + + + + + + + + + + + + + + + +
Column A {{row.a}} Column B {{row.b}} Column C {{row.c}}
+ `, + standalone: true, + imports: [MatTableModule, MatPaginatorModule, MatSortModule], +}) +class MatTableWithMultiSortApp implements OnInit { + underlyingDataSource = new FakeDataSource(); + dataSource = new MatTableDataSource(); + columnsToRender = ['column_a', 'column_b', 'column_c']; + + @ViewChild(MatTable) table: MatTable; + @ViewChild(MatSort) sort: MatSort; + + constructor() { + this.underlyingDataSource.data = []; + + // Add three rows of data + this.underlyingDataSource.addData(); + this.underlyingDataSource.addData(); + this.underlyingDataSource.addData(); + + this.underlyingDataSource.connect().subscribe(data => { + this.dataSource.data = data; + }); + } + + ngOnInit() { + this.dataSource!.sort = this.sort; + } +} + @Component({ template: ` diff --git a/tools/public_api_guard/material/sort.md b/tools/public_api_guard/material/sort.md index 78eeabc38186..4cd49ae42989 100644 --- a/tools/public_api_guard/material/sort.md +++ b/tools/public_api_guard/material/sort.md @@ -15,6 +15,7 @@ import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; import { OnInit } from '@angular/core'; import { Optional } from '@angular/core'; +import { SimpleChanges } from '@angular/core'; import { Subject } from 'rxjs'; import { WritableSignal } from '@angular/core'; @@ -51,14 +52,18 @@ export class MatSort implements OnChanges, OnDestroy, OnInit { set direction(direction: SortDirection); disableClear: boolean; disabled: boolean; + getCurrentSortDirection(id: string): SortDirection; getNextSortDirection(sortable: MatSortable): SortDirection; initialized: Observable; + isActive(id: string): boolean; + get matSortMultiple(): boolean; + set matSortMultiple(value: any); // (undocumented) static ngAcceptInputType_disableClear: unknown; // (undocumented) static ngAcceptInputType_disabled: unknown; // (undocumented) - ngOnChanges(): void; + ngOnChanges(changes: SimpleChanges): void; // (undocumented) ngOnDestroy(): void; // (undocumented) @@ -67,10 +72,11 @@ export class MatSort implements OnChanges, OnDestroy, OnInit { sort(sortable: MatSortable): void; sortables: Map; readonly sortChange: EventEmitter; + sortState: Map; start: SortDirection; readonly _stateChanges: Subject; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; }