Skip to content

Commit ecd54f4

Browse files
authored
fix(material/sort): add description input for sort-header (#23633)
Adds a description input for mat-sort-header so that developers can provide an accessible description (using AriaDescirber under the hood). Additionally update the accessibility section for the sort header's documentation with guidance on providing an accessible experience. I decided to use this approach instead of expanding `MatSortHeaderIntl` because the message developers would want to set here depends on several bits of information, including: * Whether the column is currently sorted * The sort direction * Whether users can clear sorting (configured on `MatSort`) * The name of the column (not the ID) Accounting for all of these factors would have made the intl formatting too complicated. This does have the negative consequence of needing to set a description for every header. However, users can add a custom directive to set the description in a consistent way if they have an application with many tables.
1 parent f698259 commit ecd54f4

File tree

7 files changed

+134
-21
lines changed

7 files changed

+134
-21
lines changed

src/components-examples/material/table/table-sorting/table-sorting-example.html

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,35 @@
1-
<table mat-table [dataSource]="dataSource" matSort class="mat-elevation-z8">
1+
<table mat-table [dataSource]="dataSource" matSort (matSortChange)="announceSortChange($event)"
2+
class="mat-elevation-z8">
23

34
<!-- Position Column -->
45
<ng-container matColumnDef="position">
5-
<th mat-header-cell *matHeaderCellDef mat-sort-header> No. </th>
6+
<th mat-header-cell *matHeaderCellDef mat-sort-header sortActionDescription="Sort by number">
7+
No.
8+
</th>
69
<td mat-cell *matCellDef="let element"> {{element.position}} </td>
710
</ng-container>
811

912
<!-- Name Column -->
1013
<ng-container matColumnDef="name">
11-
<th mat-header-cell *matHeaderCellDef mat-sort-header> Name </th>
14+
<th mat-header-cell *matHeaderCellDef mat-sort-header sortActionDescription="Sort by name">
15+
Name
16+
</th>
1217
<td mat-cell *matCellDef="let element"> {{element.name}} </td>
1318
</ng-container>
1419

1520
<!-- Weight Column -->
1621
<ng-container matColumnDef="weight">
17-
<th mat-header-cell *matHeaderCellDef mat-sort-header> Weight </th>
22+
<th mat-header-cell *matHeaderCellDef mat-sort-header sortActionDescription="Sort by weight">
23+
Weight
24+
</th>
1825
<td mat-cell *matCellDef="let element"> {{element.weight}} </td>
1926
</ng-container>
2027

2128
<!-- Symbol Column -->
2229
<ng-container matColumnDef="symbol">
23-
<th mat-header-cell *matHeaderCellDef mat-sort-header> Symbol </th>
30+
<th mat-header-cell *matHeaderCellDef mat-sort-header sortActionDescription="Sort by symbol">
31+
Symbol
32+
</th>
2433
<td mat-cell *matCellDef="let element"> {{element.symbol}} </td>
2534
</ng-container>
2635

src/components-examples/material/table/table-sorting/table-sorting-example.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import {LiveAnnouncer} from '@angular/cdk/a11y';
12
import {AfterViewInit, Component, ViewChild} from '@angular/core';
2-
import {MatSort} from '@angular/material/sort';
3+
import {MatSort, Sort} from '@angular/material/sort';
34
import {MatTableDataSource} from '@angular/material/table';
45

56
export interface PeriodicElement {
@@ -34,9 +35,24 @@ export class TableSortingExample implements AfterViewInit {
3435
displayedColumns: string[] = ['position', 'name', 'weight', 'symbol'];
3536
dataSource = new MatTableDataSource(ELEMENT_DATA);
3637

38+
constructor(private _liveAnnouncer: LiveAnnouncer) {}
39+
3740
@ViewChild(MatSort) sort: MatSort;
3841

3942
ngAfterViewInit() {
4043
this.dataSource.sort = this.sort;
4144
}
45+
46+
/** Announce the change in sort state for assistive technology. */
47+
announceSortChange(sortState: Sort) {
48+
// This example uses English messages. If your application supports
49+
// multiple language, you would internationalize these strings.
50+
// Furthermore, you can customize the message to add additional
51+
// details about the values being sorted.
52+
if (sortState.direction) {
53+
this._liveAnnouncer.announce(`Sorted ${sortState.direction}ending`);
54+
} else {
55+
this._liveAnnouncer.announce('Sorting cleared');
56+
}
57+
}
4258
}

src/material/sort/sort-header-intl.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@ import {Subject} from 'rxjs';
1212
/**
1313
* To modify the labels and text displayed, create a new instance of MatSortHeaderIntl and
1414
* include it in a custom provider.
15-
* @deprecated No longer being used. To be removed.
16-
* @breaking-change 13.0.0
1715
*/
1816
@Injectable({providedIn: 'root'})
1917
export class MatSortHeaderIntl {

src/material/sort/sort-header.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,23 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {AriaDescriber, FocusMonitor} from '@angular/cdk/a11y';
910
import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
11+
import {ENTER, SPACE} from '@angular/cdk/keycodes';
1012
import {
13+
AfterViewInit,
1114
ChangeDetectionStrategy,
1215
ChangeDetectorRef,
1316
Component,
17+
ElementRef,
18+
Inject,
1419
Input,
1520
OnDestroy,
1621
OnInit,
1722
Optional,
1823
ViewEncapsulation,
19-
Inject,
20-
ElementRef,
21-
AfterViewInit,
2224
} from '@angular/core';
2325
import {CanDisable, mixinDisabled} from '@angular/material/core';
24-
import {FocusMonitor} from '@angular/cdk/a11y';
25-
import {ENTER, SPACE} from '@angular/cdk/keycodes';
2626
import {merge, Subscription} from 'rxjs';
2727
import {MatSort, MatSortable} from './sort';
2828
import {matSortAnimations} from './sort-animations';
@@ -99,6 +99,12 @@ export class MatSortHeader extends _MatSortHeaderBase
9999
implements CanDisable, MatSortable, OnDestroy, OnInit, AfterViewInit {
100100
private _rerenderSubscription: Subscription;
101101

102+
/**
103+
* The element with role="button" inside this component's view. We need this
104+
* in order to apply a description with AriaDescriber.
105+
*/
106+
private _sortButton: HTMLElement;
107+
102108
/**
103109
* Flag set to true when the indicator should be displayed while the sort is not active. Used to
104110
* provide an affordance that the header is sortable by showing on focus and hover.
@@ -132,6 +138,22 @@ export class MatSortHeader extends _MatSortHeaderBase
132138
/** Overrides the sort start value of the containing MatSort for this MatSortable. */
133139
@Input() start: 'asc' | 'desc';
134140

141+
/**
142+
* Description applied to MatSortHeader's button element with aria-describedby. This text should
143+
* describe the action that will occur when the user clicks the sort header.
144+
*/
145+
@Input()
146+
get sortActionDescription(): string {
147+
return this._sortActionDescription;
148+
}
149+
set sortActionDescription(value: string) {
150+
this._updateSortActionDescription(value);
151+
}
152+
// Default the action description to "Sort" because it's better than nothing.
153+
// Without a description, the button's label comes from the sort header text content,
154+
// which doesn't give any indication that it performs a sorting operation.
155+
private _sortActionDescription: string = 'Sort';
156+
135157
/** Overrides the disable clear value of the containing MatSort for this MatSortable. */
136158
@Input()
137159
get disableClear(): boolean { return this._disableClear; }
@@ -151,7 +173,9 @@ export class MatSortHeader extends _MatSortHeaderBase
151173
@Inject('MAT_SORT_HEADER_COLUMN_DEF') @Optional()
152174
public _columnDef: MatSortHeaderColumnDef,
153175
private _focusMonitor: FocusMonitor,
154-
private _elementRef: ElementRef<HTMLElement>) {
176+
private _elementRef: ElementRef<HTMLElement>,
177+
/** @breaking-change 14.0.0 _ariaDescriber will be required. */
178+
@Optional() private _ariaDescriber?: AriaDescriber | null) {
155179
// Note that we use a string token for the `_columnDef`, because the value is provided both by
156180
// `material/table` and `cdk/table` and we can't have the CDK depending on Material,
157181
// and we want to avoid having the sort header depending on the CDK table because
@@ -176,6 +200,9 @@ export class MatSortHeader extends _MatSortHeaderBase
176200
{toState: this._isSorted() ? 'active' : this._arrowDirection});
177201

178202
this._sort.register(this);
203+
204+
this._sortButton = this._elementRef.nativeElement.querySelector('[role="button"]')!;
205+
this._updateSortActionDescription(this._sortActionDescription);
179206
}
180207

181208
ngAfterViewInit() {
@@ -310,6 +337,23 @@ export class MatSortHeader extends _MatSortHeaderBase
310337
return !this._isDisabled() || this._isSorted();
311338
}
312339

340+
private _updateSortActionDescription(newDescription: string) {
341+
// We use AriaDescriber for the sort button instead of setting an `aria-label` because some
342+
// screen readers (notably VoiceOver) will read both the column header *and* the button's label
343+
// for every *cell* in the table, creating a lot of unnecessary noise.
344+
345+
// If _sortButton is undefined, the component hasn't been initialized yet so there's
346+
// nothing to update in the DOM.
347+
if (this._sortButton) {
348+
// removeDescription will no-op if there is no existing message.
349+
// TODO(jelbourn): remove optional chaining when AriaDescriber is required.
350+
this._ariaDescriber?.removeDescription(this._sortButton, this._sortActionDescription);
351+
this._ariaDescriber?.describe(this._sortButton, newDescription);
352+
}
353+
354+
this._sortActionDescription = newDescription;
355+
}
356+
313357
/** Handles changes in the sorting state. */
314358
private _handleStateChanges() {
315359
this._rerenderSubscription =

src/material/sort/sort.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,19 @@ by default it will use the id of the column.
4040
<!-- example(table-sorting) -->
4141

4242
### Accessibility
43-
The `aria-label` for the sort button can be set in `MatSortHeaderIntl`.
43+
44+
When you apply `MatSortHeader` to a header cell element, the component wraps the content of the
45+
header cell inside a button. The text content of the header cell then becomes the accessible
46+
label for the sort button. However, the header cell text typically describes the column and does
47+
not indicate that interacting with the control performs a sorting action. To clearly communicate
48+
that the header performs sorting, always use the `sortActionDescription` input to provide a
49+
description for the button element, such as "Sort by last name".
50+
51+
`MatSortHeader` applies the `aria-sort` attribute to communicate the active sort state to
52+
assistive technology. However, most screen readers do not announce changes to the value of
53+
`aria-sort`, meaning that screen reader users do not receive feedback that sorting occured. To
54+
remedy this, use the `matSortChange` event on the `MatSort` directive to announce state
55+
updates with the `LiveAnnouncer` service from `@angular/cdk/a11y`.
56+
57+
If your application contains many tables and sort headers, consider creating a custom
58+
directives to consistently apply `sortActionDescription` and announce sort state changes.

src/material/sort/sort.spec.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,32 @@ describe('MatSort', () => {
411411

412412
expect(container.classList.contains('mat-focus-indicator')).toBe(true);
413413
});
414-
});
414+
415+
it('should add a default aria description to sort buttons', () => {
416+
const sortButton = fixture.nativeElement.querySelector('[role="button"]');
417+
const descriptionId = sortButton.getAttribute('aria-describedby');
418+
expect(descriptionId).toBeDefined();
419+
420+
const descriptionElement = document.getElementById(descriptionId);
421+
expect(descriptionElement?.textContent).toBe('Sort');
422+
});
423+
424+
it('should add a custom aria description to sort buttons', () => {
425+
const sortButton = fixture.nativeElement.querySelector('#defaultB [role="button"]');
426+
let descriptionId = sortButton.getAttribute('aria-describedby');
427+
expect(descriptionId).toBeDefined();
428+
429+
let descriptionElement = document.getElementById(descriptionId);
430+
expect(descriptionElement?.textContent).toBe('Sort second column');
431+
432+
fixture.componentInstance.secondColumnDescription = 'Sort 2nd column';
433+
fixture.detectChanges();
434+
descriptionId = sortButton.getAttribute('aria-describedby');
435+
descriptionElement = document.getElementById(descriptionId);
436+
expect(descriptionElement?.textContent).toBe('Sort 2nd column');
437+
438+
});
439+
});
415440

416441
describe('with default options', () => {
417442
let fixture: ComponentFixture<MatSortWithoutExplicitInputs>;
@@ -507,7 +532,8 @@ type SimpleMatSortAppColumnIds = 'defaultA' | 'defaultB' | 'overrideStart' | 'ov
507532
</div>
508533
<div id="defaultB"
509534
#defaultB
510-
mat-sort-header="defaultB">
535+
mat-sort-header="defaultB"
536+
[sortActionDescription]="secondColumnDescription">
511537
B
512538
</div>
513539
<div id="overrideStart"
@@ -533,6 +559,7 @@ class SimpleMatSortApp {
533559
disableClear: boolean;
534560
disabledColumnSort = false;
535561
disableAllSort = false;
562+
secondColumnDescription = 'Sort second column';
536563

537564
@ViewChild(MatSort) matSort: MatSort;
538565
@ViewChild('defaultA') defaultA: MatSortHeader;

tools/public_api_guard/material/sort.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { _AbstractConstructor } from '@angular/material/core';
88
import { AfterViewInit } from '@angular/core';
99
import { AnimationTriggerMetadata } from '@angular/animations';
10+
import { AriaDescriber } from '@angular/cdk/a11y';
1011
import { BooleanInput } from '@angular/cdk/coercion';
1112
import { CanDisable } from '@angular/material/core';
1213
import { ChangeDetectorRef } from '@angular/core';
@@ -106,7 +107,8 @@ export interface MatSortDefaultOptions {
106107
// @public
107108
export class MatSortHeader extends _MatSortHeaderBase implements CanDisable, MatSortable, OnDestroy, OnInit, AfterViewInit {
108109
constructor(
109-
_intl: MatSortHeaderIntl, _changeDetectorRef: ChangeDetectorRef, _sort: MatSort, _columnDef: MatSortHeaderColumnDef, _focusMonitor: FocusMonitor, _elementRef: ElementRef<HTMLElement>);
110+
_intl: MatSortHeaderIntl, _changeDetectorRef: ChangeDetectorRef, _sort: MatSort, _columnDef: MatSortHeaderColumnDef, _focusMonitor: FocusMonitor, _elementRef: ElementRef<HTMLElement>,
111+
_ariaDescriber?: AriaDescriber | null | undefined);
110112
_arrowDirection: SortDirection;
111113
arrowPosition: 'before' | 'after';
112114
// (undocumented)
@@ -143,17 +145,19 @@ export class MatSortHeader extends _MatSortHeaderBase implements CanDisable, Mat
143145
_showIndicatorHint: boolean;
144146
// (undocumented)
145147
_sort: MatSort;
148+
get sortActionDescription(): string;
149+
set sortActionDescription(value: string);
146150
start: 'asc' | 'desc';
147151
_toggleOnInteraction(): void;
148152
_updateArrowDirection(): void;
149153
_viewState: ArrowViewStateTransition;
150154
// (undocumented)
151-
static ɵcmp: i0.ɵɵComponentDeclaration<MatSortHeader, "[mat-sort-header]", ["matSortHeader"], { "disabled": "disabled"; "id": "mat-sort-header"; "arrowPosition": "arrowPosition"; "start": "start"; "disableClear": "disableClear"; }, {}, never, ["*"]>;
155+
static ɵcmp: i0.ɵɵComponentDeclaration<MatSortHeader, "[mat-sort-header]", ["matSortHeader"], { "disabled": "disabled"; "id": "mat-sort-header"; "arrowPosition": "arrowPosition"; "start": "start"; "sortActionDescription": "sortActionDescription"; "disableClear": "disableClear"; }, {}, never, ["*"]>;
152156
// (undocumented)
153-
static ɵfac: i0.ɵɵFactoryDeclaration<MatSortHeader, [null, null, { optional: true; }, { optional: true; }, null, null]>;
157+
static ɵfac: i0.ɵɵFactoryDeclaration<MatSortHeader, [null, null, { optional: true; }, { optional: true; }, null, null, { optional: true; }]>;
154158
}
155159

156-
// @public @deprecated
160+
// @public
157161
export class MatSortHeaderIntl {
158162
readonly changes: Subject<void>;
159163
// (undocumented)

0 commit comments

Comments
 (0)