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
+
+
-
-
-
- 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}} |
|
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: `
+
+
+ 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;
}
| | |