Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(components/sort): add multi-sort support #28458

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -5,3 +5,7 @@ table {
th.mat-sort-header-sorted {
color: black;
}

.example-sorting-toggle-group {
margin: 8px;
}
@@ -1,36 +1,52 @@
<div>
<mat-button-toggle-group #multiSorting="matButtonToggleGroup" class="example-sorting-toggle-group">
<mat-button-toggle [value]="false">Single column sorting</mat-button-toggle>
<mat-button-toggle [value]="true">Multi column sorting</mat-button-toggle>
</mat-button-toggle-group>
</div>

<table mat-table [dataSource]="dataSource" matSort (matSortChange)="announceSortChange($event)"
[matSortMultiple]="multiSorting.value"
class="mat-elevation-z8">

<!-- Position Column -->
<ng-container matColumnDef="position">
<ng-container matColumnDef="firstName">
<th mat-header-cell *matHeaderCellDef mat-sort-header sortActionDescription="Sort by number">
No.
First name
</th>
<td mat-cell *matCellDef="let element"> {{element.position}} </td>
<td mat-cell *matCellDef="let element"> {{element.firstName}} </td>
</ng-container>

<!-- Name Column -->
<ng-container matColumnDef="name">
<ng-container matColumnDef="lastName">
<th mat-header-cell *matHeaderCellDef mat-sort-header sortActionDescription="Sort by name">
Name
Last name
</th>
<td mat-cell *matCellDef="let element"> {{element.name}} </td>
<td mat-cell *matCellDef="let element"> {{element.lastName}} </td>
</ng-container>

<!-- Weight Column -->
<ng-container matColumnDef="weight">
<ng-container matColumnDef="position">
<th mat-header-cell *matHeaderCellDef mat-sort-header sortActionDescription="Sort by weight">
Weight
Position
</th>
<td mat-cell *matCellDef="let element"> {{element.position}} </td>
</ng-container>

<!-- Symbol Column -->
<ng-container matColumnDef="office">
<th mat-header-cell *matHeaderCellDef mat-sort-header sortActionDescription="Sort by symbol">
Office
</th>
<td mat-cell *matCellDef="let element"> {{element.weight}} </td>
<td mat-cell *matCellDef="let element"> {{element.office}} </td>
</ng-container>

<!-- Symbol Column -->
<ng-container matColumnDef="symbol">
<ng-container matColumnDef="salary">
<th mat-header-cell *matHeaderCellDef mat-sort-header sortActionDescription="Sort by symbol">
Symbol
Salary
</th>
<td mat-cell *matCellDef="let element"> {{element.symbol}} </td>
<td mat-cell *matCellDef="let element"> {{element.salary}} </td>
</ng-container>

<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
Expand Down
Expand Up @@ -2,25 +2,34 @@ import {LiveAnnouncer} from '@angular/cdk/a11y';
import {AfterViewInit, Component, ViewChild} from '@angular/core';
import {MatSort, Sort, MatSortModule} from '@angular/material/sort';
import {MatTableDataSource, MatTableModule} from '@angular/material/table';
import {MatButtonToggle, MatButtonToggleGroup} from '@angular/material/button-toggle';
import {MatButton} from '@angular/material/button';

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 MULTI_SORT_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
*/
Expand All @@ -29,11 +38,11 @@ const ELEMENT_DATA: PeriodicElement[] = [
styleUrl: 'table-sorting-example.css',
templateUrl: 'table-sorting-example.html',
standalone: true,
imports: [MatTableModule, MatSortModule],
imports: [MatTableModule, MatSortModule, MatButtonToggle, MatButtonToggleGroup, MatButton],
})
export class TableSortingExample implements AfterViewInit {
displayedColumns: string[] = ['position', 'name', 'weight', 'symbol'];
dataSource = new MatTableDataSource(ELEMENT_DATA);
displayedColumns: string[] = ['firstName', 'lastName', 'position', 'office', 'salary'];
dataSource = new MatTableDataSource(MULTI_SORT_DATA);

constructor(private _liveAnnouncer: LiveAnnouncer) {}

Expand Down
11 changes: 7 additions & 4 deletions src/material/sort/sort-header.ts
Expand Up @@ -294,9 +294,10 @@ 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')
);
}

Expand All @@ -322,7 +323,9 @@ export class MatSortHeader implements MatSortable, OnDestroy, OnInit, AfterViewI
* only be changed once the arrow displays again (hint or activation).
*/
_updateArrowDirection() {
this._arrowDirection = this._isSorted() ? this._sort.direction : this.start || this._sort.start;
this._arrowDirection = this._isSorted()
? this._sort.getCurrentSortDirection(this.id)
: this.start || this._sort.start;
}

_isDisabled() {
Expand All @@ -340,7 +343,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. */
Expand Down
21 changes: 21 additions & 0 deletions src/material/sort/sort.spec.ts
Expand Up @@ -57,6 +57,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', () => {
Expand Down Expand Up @@ -445,6 +448,24 @@ 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);

Expand Down
85 changes: 79 additions & 6 deletions src/material/sort/sort.ts
Expand Up @@ -7,6 +7,7 @@
*/

import {
booleanAttribute,
Directive,
EventEmitter,
Inject,
Expand All @@ -17,7 +18,7 @@ import {
OnInit,
Optional,
Output,
booleanAttribute,
SimpleChanges,
} from '@angular/core';
import {Observable, ReplaySubject, Subject} from 'rxjs';
import {SortDirection} from './sort-direction';
Expand All @@ -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';
Expand Down Expand Up @@ -79,6 +81,9 @@ export class MatSort implements OnChanges, OnDestroy, OnInit {
/** Collection of all registered sortables that this directive manages. */
sortables = new Map<string, MatSortable>();

/** Map holding the sort state for each column */
sortState = new Map<string, Sort>;

/** Used to notify any child components listening to state changes. */
readonly _stateChanges = new Subject<void>();

Expand Down Expand Up @@ -109,6 +114,17 @@ export class MatSort implements OnChanges, OnDestroy, OnInit {
}
private _direction: SortDirection = '';

/** Whether to enable the multi-sorting feature */
@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.
Expand Down Expand Up @@ -160,14 +176,53 @@ 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});
this.sortChange.emit(currentSort);
}

/**
* 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. */
Expand All @@ -176,13 +231,14 @@ export class MatSort implements OnChanges, OnDestroy, OnInit {
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;
}
Expand All @@ -193,7 +249,24 @@ export class MatSort implements OnChanges, OnDestroy, OnInit {
this._initializedStream.next();
}

ngOnChanges() {
ngOnChanges(changes: SimpleChanges) {
/* Update sortState with updated active and direction values, otherwise sorting won't work */
if (changes['active'] || changes['direction']) {
const currentActive = changes['active']?.currentValue ?? this.active;
const currentDirection = changes['direction']?.currentValue ?? this.direction ?? this.start;


// Handle sort deactivation
if ((!currentActive || currentActive === '') && changes['active']?.previousValue) {
this.sortState.delete(changes['active'].previousValue);
} else {
this.sortState.set(currentActive, {
active: currentActive,
direction: currentDirection,
} as Sort);
}
}

this._stateChanges.next();
}

Expand Down