Skip to content

fix(material/sort): add description input for sort-header #23633

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

Merged
merged 1 commit into from
Oct 13, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,26 +1,35 @@
<table mat-table [dataSource]="dataSource" matSort class="mat-elevation-z8">
<table mat-table [dataSource]="dataSource" matSort (matSortChange)="announceSortChange($event)"
class="mat-elevation-z8">

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

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

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

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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {LiveAnnouncer} from '@angular/cdk/a11y';
import {AfterViewInit, Component, ViewChild} from '@angular/core';
import {MatSort} from '@angular/material/sort';
import {MatSort, Sort} from '@angular/material/sort';
import {MatTableDataSource} from '@angular/material/table';

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

constructor(private _liveAnnouncer: LiveAnnouncer) {}

@ViewChild(MatSort) sort: MatSort;

ngAfterViewInit() {
this.dataSource.sort = this.sort;
}

/** Announce the change in sort state for assistive technology. */
announceSortChange(sortState: Sort) {
// This example uses English messages. If your application supports
// multiple language, you would internationalize these strings.
// Furthermore, you can customize the message to add additional
// details about the values being sorted.
if (sortState.direction) {
this._liveAnnouncer.announce(`Sorted ${sortState.direction}ending`);
} else {
this._liveAnnouncer.announce('Sorting cleared');
}
}
}
2 changes: 0 additions & 2 deletions src/material/sort/sort-header-intl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ import {Subject} from 'rxjs';
/**
* To modify the labels and text displayed, create a new instance of MatSortHeaderIntl and
* include it in a custom provider.
* @deprecated No longer being used. To be removed.
* @breaking-change 13.0.0
*/
@Injectable({providedIn: 'root'})
export class MatSortHeaderIntl {
Expand Down
56 changes: 50 additions & 6 deletions src/material/sort/sort-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,23 @@
* found in the LICENSE file at https://angular.io/license
*/

import {AriaDescriber, FocusMonitor} from '@angular/cdk/a11y';
import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
import {ENTER, SPACE} from '@angular/cdk/keycodes';
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
Inject,
Input,
OnDestroy,
OnInit,
Optional,
ViewEncapsulation,
Inject,
ElementRef,
AfterViewInit,
} from '@angular/core';
import {CanDisable, mixinDisabled} from '@angular/material/core';
import {FocusMonitor} from '@angular/cdk/a11y';
import {ENTER, SPACE} from '@angular/cdk/keycodes';
import {merge, Subscription} from 'rxjs';
import {MatSort, MatSortable} from './sort';
import {matSortAnimations} from './sort-animations';
Expand Down Expand Up @@ -99,6 +99,12 @@ export class MatSortHeader extends _MatSortHeaderBase
implements CanDisable, MatSortable, OnDestroy, OnInit, AfterViewInit {
private _rerenderSubscription: Subscription;

/**
* The element with role="button" inside this component's view. We need this
* in order to apply a description with AriaDescriber.
*/
private _sortButton: HTMLElement;

/**
* Flag set to true when the indicator should be displayed while the sort is not active. Used to
* provide an affordance that the header is sortable by showing on focus and hover.
Expand Down Expand Up @@ -132,6 +138,22 @@ export class MatSortHeader extends _MatSortHeaderBase
/** Overrides the sort start value of the containing MatSort for this MatSortable. */
@Input() start: 'asc' | 'desc';

/**
* Description applied to MatSortHeader's button element with aria-describedby. This text should
* describe the action that will occur when the user clicks the sort header.
*/
@Input()
get sortActionDescription(): string {
return this._sortActionDescription;
}
set sortActionDescription(value: string) {
this._updateSortActionDescription(value);
}
// Default the action description to "Sort" because it's better than nothing.
// Without a description, the button's label comes from the sort header text content,
// which doesn't give any indication that it performs a sorting operation.
private _sortActionDescription: string = 'Sort';

/** Overrides the disable clear value of the containing MatSort for this MatSortable. */
@Input()
get disableClear(): boolean { return this._disableClear; }
Expand All @@ -151,7 +173,9 @@ export class MatSortHeader extends _MatSortHeaderBase
@Inject('MAT_SORT_HEADER_COLUMN_DEF') @Optional()
public _columnDef: MatSortHeaderColumnDef,
private _focusMonitor: FocusMonitor,
private _elementRef: ElementRef<HTMLElement>) {
private _elementRef: ElementRef<HTMLElement>,
/** @breaking-change 14.0.0 _ariaDescriber will be required. */
@Optional() private _ariaDescriber?: AriaDescriber | null) {
// Note that we use a string token for the `_columnDef`, because the value is provided both by
// `material/table` and `cdk/table` and we can't have the CDK depending on Material,
// and we want to avoid having the sort header depending on the CDK table because
Expand All @@ -176,6 +200,9 @@ export class MatSortHeader extends _MatSortHeaderBase
{toState: this._isSorted() ? 'active' : this._arrowDirection});

this._sort.register(this);

this._sortButton = this._elementRef.nativeElement.querySelector('[role="button"]')!;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use a ViewChild query to get the button instead of querying directly?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO I don't see the point of using @ViewChild to query for native DOM elements (vs. directives or injectables)

this._updateSortActionDescription(this._sortActionDescription);
}

ngAfterViewInit() {
Expand Down Expand Up @@ -310,6 +337,23 @@ export class MatSortHeader extends _MatSortHeaderBase
return !this._isDisabled() || this._isSorted();
}

private _updateSortActionDescription(newDescription: string) {
// We use AriaDescriber for the sort button instead of setting an `aria-label` because some
// screen readers (notably VoiceOver) will read both the column header *and* the button's label
// for every *cell* in the table, creating a lot of unnecessary noise.

// If _sortButton is undefined, the component hasn't been initialized yet so there's
// nothing to update in the DOM.
if (this._sortButton) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we also need to check that the description is defined/isn't an empty string here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AriaDescriber already checks the presence of the message internally

// removeDescription will no-op if there is no existing message.
// TODO(jelbourn): remove optional chaining when AriaDescriber is required.
this._ariaDescriber?.removeDescription(this._sortButton, this._sortActionDescription);
this._ariaDescriber?.describe(this._sortButton, newDescription);
}

this._sortActionDescription = newDescription;
}

/** Handles changes in the sorting state. */
private _handleStateChanges() {
this._rerenderSubscription =
Expand Down
17 changes: 16 additions & 1 deletion src/material/sort/sort.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,19 @@ by default it will use the id of the column.
<!-- example(table-sorting) -->

### Accessibility
The `aria-label` for the sort button can be set in `MatSortHeaderIntl`.

When you apply `MatSortHeader` to a header cell element, the component wraps the content of the
header cell inside a button. The text content of the header cell then becomes the accessible
label for the sort button. However, the header cell text typically describes the column and does
not indicate that interacting with the control performs a sorting action. To clearly communicate
that the header performs sorting, always use the `sortActionDescription` input to provide a
description for the button element, such as "Sort by last name".

`MatSortHeader` applies the `aria-sort` attribute to communicate the active sort state to
assistive technology. However, most screen readers do not announce changes to the value of
`aria-sort`, meaning that screen reader users do not receive feedback that sorting occured. To
remedy this, use the `matSortChange` event on the `MatSort` directive to announce state
updates with the `LiveAnnouncer` service from `@angular/cdk/a11y`.

If your application contains many tables and sort headers, consider creating a custom
directives to consistently apply `sortActionDescription` and announce sort state changes.
31 changes: 29 additions & 2 deletions src/material/sort/sort.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,32 @@ describe('MatSort', () => {

expect(container.classList.contains('mat-focus-indicator')).toBe(true);
});
});

it('should add a default aria description to sort buttons', () => {
const sortButton = fixture.nativeElement.querySelector('[role="button"]');
const descriptionId = sortButton.getAttribute('aria-describedby');
expect(descriptionId).toBeDefined();

const descriptionElement = document.getElementById(descriptionId);
expect(descriptionElement?.textContent).toBe('Sort');
});

it('should add a custom aria description to sort buttons', () => {
const sortButton = fixture.nativeElement.querySelector('#defaultB [role="button"]');
let descriptionId = sortButton.getAttribute('aria-describedby');
expect(descriptionId).toBeDefined();

let descriptionElement = document.getElementById(descriptionId);
expect(descriptionElement?.textContent).toBe('Sort second column');

fixture.componentInstance.secondColumnDescription = 'Sort 2nd column';
fixture.detectChanges();
descriptionId = sortButton.getAttribute('aria-describedby');
descriptionElement = document.getElementById(descriptionId);
expect(descriptionElement?.textContent).toBe('Sort 2nd column');

});
});

describe('with default options', () => {
let fixture: ComponentFixture<MatSortWithoutExplicitInputs>;
Expand Down Expand Up @@ -507,7 +532,8 @@ type SimpleMatSortAppColumnIds = 'defaultA' | 'defaultB' | 'overrideStart' | 'ov
</div>
<div id="defaultB"
#defaultB
mat-sort-header="defaultB">
mat-sort-header="defaultB"
[sortActionDescription]="secondColumnDescription">
B
</div>
<div id="overrideStart"
Expand All @@ -533,6 +559,7 @@ class SimpleMatSortApp {
disableClear: boolean;
disabledColumnSort = false;
disableAllSort = false;
secondColumnDescription = 'Sort second column';

@ViewChild(MatSort) matSort: MatSort;
@ViewChild('defaultA') defaultA: MatSortHeader;
Expand Down
12 changes: 8 additions & 4 deletions tools/public_api_guard/material/sort.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { _AbstractConstructor } from '@angular/material/core';
import { AfterViewInit } from '@angular/core';
import { AnimationTriggerMetadata } from '@angular/animations';
import { AriaDescriber } from '@angular/cdk/a11y';
import { BooleanInput } from '@angular/cdk/coercion';
import { CanDisable } from '@angular/material/core';
import { ChangeDetectorRef } from '@angular/core';
Expand Down Expand Up @@ -106,7 +107,8 @@ export interface MatSortDefaultOptions {
// @public
export class MatSortHeader extends _MatSortHeaderBase implements CanDisable, MatSortable, OnDestroy, OnInit, AfterViewInit {
constructor(
_intl: MatSortHeaderIntl, _changeDetectorRef: ChangeDetectorRef, _sort: MatSort, _columnDef: MatSortHeaderColumnDef, _focusMonitor: FocusMonitor, _elementRef: ElementRef<HTMLElement>);
_intl: MatSortHeaderIntl, _changeDetectorRef: ChangeDetectorRef, _sort: MatSort, _columnDef: MatSortHeaderColumnDef, _focusMonitor: FocusMonitor, _elementRef: ElementRef<HTMLElement>,
_ariaDescriber?: AriaDescriber | null | undefined);
_arrowDirection: SortDirection;
arrowPosition: 'before' | 'after';
// (undocumented)
Expand Down Expand Up @@ -143,17 +145,19 @@ export class MatSortHeader extends _MatSortHeaderBase implements CanDisable, Mat
_showIndicatorHint: boolean;
// (undocumented)
_sort: MatSort;
get sortActionDescription(): string;
set sortActionDescription(value: string);
start: 'asc' | 'desc';
_toggleOnInteraction(): void;
_updateArrowDirection(): void;
_viewState: ArrowViewStateTransition;
// (undocumented)
static ɵcmp: i0.ɵɵComponentDeclaration<MatSortHeader, "[mat-sort-header]", ["matSortHeader"], { "disabled": "disabled"; "id": "mat-sort-header"; "arrowPosition": "arrowPosition"; "start": "start"; "disableClear": "disableClear"; }, {}, never, ["*"]>;
static ɵcmp: i0.ɵɵComponentDeclaration<MatSortHeader, "[mat-sort-header]", ["matSortHeader"], { "disabled": "disabled"; "id": "mat-sort-header"; "arrowPosition": "arrowPosition"; "start": "start"; "sortActionDescription": "sortActionDescription"; "disableClear": "disableClear"; }, {}, never, ["*"]>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<MatSortHeader, [null, null, { optional: true; }, { optional: true; }, null, null]>;
static ɵfac: i0.ɵɵFactoryDeclaration<MatSortHeader, [null, null, { optional: true; }, { optional: true; }, null, null, { optional: true; }]>;
}

// @public @deprecated
// @public
export class MatSortHeaderIntl {
readonly changes: Subject<void>;
// (undocumented)
Expand Down