Skip to content

Commit

Permalink
feat(components/sort): add multi-sort support
Browse files Browse the repository at this point in the history
Adds multi-column sorting capability to MatSort, allowing to sort a table on multiple of its
columns at once by toggling matSortMultiple.

This feature adds a new sortState variable inside MatSort that should be used as a source of truth
when matSortMultiple is enabled.
Fixes angular#24102
  • Loading branch information
Israel Merljak committed Jan 21, 2024
1 parent dabb967 commit 572a6f2
Show file tree
Hide file tree
Showing 9 changed files with 391 additions and 96 deletions.
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[] = [
styleUrls: ['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 {HasInitialized, mixinInitialized} from '@angular/material/core';
import {Subject} from 'rxjs';
Expand All @@ -27,6 +28,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 @@ -82,6 +84,9 @@ export class MatSort extends _MatSortBase implements HasInitialized, OnChanges,
/** 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 @@ -112,6 +117,17 @@ export class MatSort extends _MatSortBase implements HasInitialized, OnChanges,
}
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 @@ -162,14 +178,53 @@ export class MatSort extends _MatSortBase implements HasInitialized, OnChanges,

/** 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 @@ -178,13 +233,14 @@ export class MatSort extends _MatSortBase implements HasInitialized, OnChanges,
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 @@ -195,7 +251,24 @@ export class MatSort extends _MatSortBase implements HasInitialized, OnChanges,
this._markInitialized();
}

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

0 comments on commit 572a6f2

Please sign in to comment.