Skip to content

Commit ea22e92

Browse files
crisbetoandrewseguin
authored andcommitted
fix(sort): sorted state being read out when navigating cells
Reworks the sort header to allow for screen readers to read out its sorted state through `aria-sort`, rather than having to declare an `aria-label` inside it. The drawback of the `aria-label` approach is that the label will also be read out as the user is navigating the table's cells. Fixes #13012.
1 parent 6d2fb1f commit ea22e92

File tree

10 files changed

+93
-91
lines changed

10 files changed

+93
-91
lines changed

src/material/core/focus-indicators/_focus-indicators.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
.mat-focus-indicator.mat-fab::before,
4242
.mat-focus-indicator.mat-mini-fab::before,
4343
.mat-focus-indicator.mat-chip::before,
44-
.mat-focus-indicator.mat-sort-header-button::before {
44+
.mat-focus-indicator.mat-sort-header-container::before {
4545
margin: -($border-width + 2px);
4646
}
4747

src/material/sort/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ ng_module(
2121
deps = [
2222
"//src/cdk/a11y",
2323
"//src/cdk/coercion",
24+
"//src/cdk/keycodes",
2425
"//src/material/core",
2526
"@npm//@angular/animations",
2627
"@npm//@angular/common",

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@ export class MatSortHeaderIntl {
2121
*/
2222
readonly changes: Subject<void> = new Subject<void>();
2323

24-
/** ARIA label for the sorting button. */
24+
/**
25+
* ARIA label for the sorting button.
26+
* @deprecated Not used anymore. To be removed.
27+
* @breaking-change 8.0.0
28+
*/
2529
sortButtonLabel = (id: string) => {
2630
return `Change sorting for ${id}`;
2731
}

src/material/sort/sort-header.html

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,28 @@
1-
<div class="mat-sort-header-container"
1+
<!--
2+
We set the `tabindex` on an element inside the table header, rather than the header itself,
3+
because of a bug in NVDA where having a `tabindex` on a `th` breaks keyboard navigation in the
4+
table (see https://github.com/nvaccess/nvda/issues/7718). This allows for the header to both
5+
be focusable, and have screen readers read out its `aria-sort` state. We prefer this approach
6+
over having a button with an `aria-label` inside the header, because the button's `aria-label`
7+
will be read out as the user is navigating the table's cell (see #13012).
8+
9+
The approach is based off of: https://dequeuniversity.com/library/aria/tables/sf-sortable-grid
10+
-->
11+
<div class="mat-sort-header-container mat-focus-indicator"
212
[class.mat-sort-header-sorted]="_isSorted()"
3-
[class.mat-sort-header-position-before]="arrowPosition == 'before'">
4-
<button class="mat-sort-header-button mat-focus-indicator" type="button"
5-
[attr.disabled]="_isDisabled() || null"
6-
[attr.aria-label]="_intl.sortButtonLabel(id)">
13+
[class.mat-sort-header-position-before]="arrowPosition == 'before'"
14+
[attr.tabindex]="_isDisabled() ? null : 0"
15+
role="button">
16+
17+
<!--
18+
TODO(crisbeto): this div isn't strictly necessary, but we have to keep it due to a large
19+
number of screenshot diff failures. It should be removed eventually. Note that the difference
20+
isn't visible with a shorter header, but once it breaks up into multiple lines, this element
21+
causes it to be center-aligned, whereas removing it will keep the text to the left.
22+
-->
23+
<div class="mat-sort-header-content">
724
<ng-content></ng-content>
8-
</button>
25+
</div>
926

1027
<!-- Disable animations while a current animation is running -->
1128
<div class="mat-sort-header-arrow"

src/material/sort/sort-header.scss

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,11 @@ $mat-sort-header-arrow-hint-opacity: 0.38;
1111
display: flex;
1212
cursor: pointer;
1313
align-items: center;
14+
letter-spacing: normal;
1415

15-
.mat-sort-header-disabled & {
16-
cursor: default;
17-
}
18-
}
19-
20-
.mat-sort-header-position-before {
21-
flex-direction: row-reverse;
22-
}
23-
24-
.mat-sort-header-button {
25-
border: none;
26-
background: 0 0;
27-
display: flex;
28-
align-items: center;
29-
padding: 0;
30-
cursor: inherit;
16+
// Needs to be reset since we don't want an outline around the inner
17+
// div which is focusable. We have our own alternate focus styling.
3118
outline: 0;
32-
font: inherit;
33-
color: currentColor;
3419

3520
// Usually we could rely on the arrow showing up to be focus indication, but if a header is
3621
// active, the arrow will always be shown so the user has no way of telling the difference.
@@ -39,14 +24,21 @@ $mat-sort-header-arrow-hint-opacity: 0.38;
3924
border-bottom: solid 1px currentColor;
4025
}
4126

42-
// The `outline: 0` from above works on all browsers, however Firefox also
43-
// adds a special `focus-inner` which we have to disable explicitly. See:
44-
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Firefox
45-
&::-moz-focus-inner {
46-
border: 0;
27+
.mat-sort-header-disabled & {
28+
cursor: default;
4729
}
4830
}
4931

32+
.mat-sort-header-content {
33+
text-align: center;
34+
display: flex;
35+
align-items: center;
36+
}
37+
38+
.mat-sort-header-position-before {
39+
flex-direction: row-reverse;
40+
}
41+
5042
.mat-sort-header-arrow {
5143
height: $mat-sort-header-arrow-container-size;
5244
width: $mat-sort-header-arrow-container-size;

src/material/sort/sort-header.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from '@angular/core';
2323
import {CanDisable, CanDisableCtor, mixinDisabled} from '@angular/material/core';
2424
import {FocusMonitor} from '@angular/cdk/a11y';
25+
import {ENTER, SPACE} from '@angular/cdk/keycodes';
2526
import {merge, Subscription} from 'rxjs';
2627
import {MatSort, MatSortable} from './sort';
2728
import {matSortAnimations} from './sort-animations';
@@ -78,6 +79,7 @@ interface MatSortHeaderColumnDef {
7879
host: {
7980
'class': 'mat-sort-header',
8081
'(click)': '_handleClick()',
82+
'(keydown)': '_handleKeydown($event)',
8183
'(mouseenter)': '_setIndicatorHintVisible(true)',
8284
'(mouseleave)': '_setIndicatorHintVisible(false)',
8385
'[attr.aria-sort]': '_getAriaSortAttribute()',
@@ -233,8 +235,7 @@ export class MatSortHeader extends _MatSortHeaderMixinBase
233235
}
234236

235237
/** Triggers the sort on this sort header and removes the indicator hint. */
236-
_handleClick() {
237-
if (this._isDisabled()) { return; }
238+
_toggleOnInteraction() {
238239

239240
this._sort.sort(this);
240241

@@ -253,6 +254,19 @@ export class MatSortHeader extends _MatSortHeaderMixinBase
253254
this._showIndicatorHint = false;
254255
}
255256

257+
_handleClick() {
258+
if (!this._isDisabled()) {
259+
this._toggleOnInteraction();
260+
}
261+
}
262+
263+
_handleKeydown(event: KeyboardEvent) {
264+
if (!this._isDisabled() && (event.keyCode === SPACE || event.keyCode === ENTER)) {
265+
event.preventDefault();
266+
this._toggleOnInteraction();
267+
}
268+
}
269+
256270
/** Whether this MatSortHeader is currently sorted in either ascending or descending order. */
257271
_isSorted() {
258272
return this._sort.active == this.id &&
@@ -297,7 +311,9 @@ export class MatSortHeader extends _MatSortHeaderMixinBase
297311
* ensures this is true.
298312
*/
299313
_getAriaSortAttribute() {
300-
if (!this._isSorted()) { return null; }
314+
if (!this._isSorted()) {
315+
return 'none';
316+
}
301317

302318
return this._sort.direction == 'asc' ? 'ascending' : 'descending';
303319
}

src/material/sort/sort.spec.ts

Lines changed: 17 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,15 @@ import {
77
wrappedErrorMessage,
88
} from '@angular/cdk/testing/private';
99
import {Component, ElementRef, ViewChild} from '@angular/core';
10-
import {async, ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing';
11-
import {By} from '@angular/platform-browser';
10+
import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
1211
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
12+
import {By} from '@angular/platform-browser';
1313
import {Observable} from 'rxjs';
1414
import {map} from 'rxjs/operators';
1515
import {MatTableModule} from '../table/index';
1616
import {
1717
MatSort,
1818
MatSortHeader,
19-
MatSortHeaderIntl,
2019
MatSortModule,
2120
Sort,
2221
SortDirection
@@ -220,40 +219,40 @@ describe('MatSort', () => {
220219
});
221220

222221
it('should allow for the cycling the sort direction to be disabled per column', () => {
223-
const button = fixture.nativeElement.querySelector('#defaultA button');
222+
const container = fixture.nativeElement.querySelector('#defaultA .mat-sort-header-container');
224223

225224
component.sort('defaultA');
226225
expect(component.matSort.direction).toBe('asc');
227-
expect(button.getAttribute('disabled')).toBeFalsy();
226+
expect(container.getAttribute('tabindex')).toBe('0');
228227

229228
component.disabledColumnSort = true;
230229
fixture.detectChanges();
231230

232231
component.sort('defaultA');
233232
expect(component.matSort.direction).toBe('asc');
234-
expect(button.getAttribute('disabled')).toBe('true');
233+
expect(container.getAttribute('tabindex')).toBeFalsy();
235234
});
236235

237236
it('should allow for the cycling the sort direction to be disabled for all columns', () => {
238-
const button = fixture.nativeElement.querySelector('#defaultA button');
237+
const container = fixture.nativeElement.querySelector('#defaultA .mat-sort-header-container');
239238

240239
component.sort('defaultA');
241240
expect(component.matSort.active).toBe('defaultA');
242241
expect(component.matSort.direction).toBe('asc');
243-
expect(button.getAttribute('disabled')).toBeFalsy();
242+
expect(container.getAttribute('tabindex')).toBe('0');
244243

245244
component.disableAllSort = true;
246245
fixture.detectChanges();
247246

248247
component.sort('defaultA');
249248
expect(component.matSort.active).toBe('defaultA');
250249
expect(component.matSort.direction).toBe('asc');
251-
expect(button.getAttribute('disabled')).toBe('true');
250+
expect(container.getAttribute('tabindex')).toBeFalsy();
252251

253252
component.sort('defaultB');
254253
expect(component.matSort.active).toBe('defaultA');
255254
expect(component.matSort.direction).toBe('asc');
256-
expect(button.getAttribute('disabled')).toBe('true');
255+
expect(container.getAttribute('tabindex')).toBeFalsy();
257256
});
258257

259258
it('should reset sort direction when a different column is sorted', () => {
@@ -301,29 +300,24 @@ describe('MatSort', () => {
301300
fixture, ['asc', 'desc'], 'overrideDisableClear');
302301
});
303302

304-
it('should apply the aria-labels to the button', () => {
305-
const button = fixture.nativeElement.querySelector('#defaultA button');
306-
expect(button.getAttribute('aria-label')).toBe('Change sorting for defaultA');
307-
});
308-
309303
it('should toggle indicator hint on button focus/blur and hide on click', () => {
310304
const header = fixture.componentInstance.defaultA;
311-
const button = fixture.nativeElement.querySelector('#defaultA button');
305+
const container = fixture.nativeElement.querySelector('#defaultA .mat-sort-header-container');
312306
const focusEvent = createFakeEvent('focus');
313307
const blurEvent = createFakeEvent('blur');
314308

315309
// Should start without a displayed hint
316310
expect(header._showIndicatorHint).toBeFalsy();
317311

318312
// Focusing the button should show the hint, blurring should hide it
319-
button.dispatchEvent(focusEvent);
313+
container.dispatchEvent(focusEvent);
320314
expect(header._showIndicatorHint).toBeTruthy();
321315

322-
button.dispatchEvent(blurEvent);
316+
container.dispatchEvent(blurEvent);
323317
expect(header._showIndicatorHint).toBeFalsy();
324318

325319
// Show the indicator hint. On click the hint should be hidden
326-
button.dispatchEvent(focusEvent);
320+
container.dispatchEvent(focusEvent);
327321
expect(header._showIndicatorHint).toBeTruthy();
328322

329323
header._handleClick();
@@ -356,7 +350,7 @@ describe('MatSort', () => {
356350

357351
it('should apply the aria-sort label to the header when sorted', () => {
358352
const sortHeaderElement = fixture.nativeElement.querySelector('#defaultA');
359-
expect(sortHeaderElement.getAttribute('aria-sort')).toBe(null);
353+
expect(sortHeaderElement.getAttribute('aria-sort')).toBe('none');
360354

361355
component.sort('defaultA');
362356
fixture.detectChanges();
@@ -368,22 +362,9 @@ describe('MatSort', () => {
368362

369363
component.sort('defaultA');
370364
fixture.detectChanges();
371-
expect(sortHeaderElement.getAttribute('aria-sort')).toBe(null);
365+
expect(sortHeaderElement.getAttribute('aria-sort')).toBe('none');
372366
});
373367

374-
it('should re-render when the i18n labels have changed',
375-
inject([MatSortHeaderIntl], (intl: MatSortHeaderIntl) => {
376-
const header = fixture.debugElement.query(By.directive(MatSortHeader))!.nativeElement;
377-
const button = header.querySelector('.mat-sort-header-button');
378-
379-
intl.sortButtonLabel = () => 'Sort all of the things';
380-
intl.changes.next();
381-
fixture.detectChanges();
382-
383-
expect(button.getAttribute('aria-label')).toBe('Sort all of the things');
384-
})
385-
);
386-
387368
it('should not render the arrow if sorting is disabled for that column', fakeAsync(() => {
388369
const sortHeaderElement = fixture.nativeElement.querySelector('#defaultA');
389370

@@ -412,9 +393,9 @@ describe('MatSort', () => {
412393
it('should have a focus indicator', () => {
413394
const headerNativeElement =
414395
fixture.debugElement.query(By.directive(MatSortHeader))!.nativeElement;
415-
const buttonNativeElement = headerNativeElement.querySelector('.mat-sort-header-button');
396+
const container = headerNativeElement.querySelector('.mat-sort-header-container');
416397

417-
expect(buttonNativeElement.classList.contains('mat-focus-indicator')).toBe(true);
398+
expect(container.classList.contains('mat-focus-indicator')).toBe(true);
418399
});
419400

420401
});

src/material/sort/testing/shared.spec.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -69,20 +69,6 @@ export function runHarnessTests(
6969
expect(labels).toEqual(['Dessert', 'Calories', 'Fat', 'Carbs', 'Protein']);
7070
});
7171

72-
it('should be able to get the aria-label of a header', async () => {
73-
const sort = await loader.getHarness(sortHarness);
74-
const headers = await sort.getSortHeaders();
75-
const labels = await Promise.all(headers.map(header => header.getAriaLabel()));
76-
77-
expect(labels).toEqual([
78-
'Change sorting for name',
79-
'Change sorting for calories',
80-
'Change sorting for fat',
81-
'Change sorting for carbs',
82-
'Change sorting for protein'
83-
]);
84-
});
85-
8672
it('should get the disabled state of a header', async () => {
8773
const sort = await loader.getHarness(sortHarness);
8874
const thirdHeader = (await sort.getSortHeaders())[2];

src/material/sort/testing/sort-header-harness.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {SortHeaderHarnessFilters} from './sort-harness-filters';
1313
/** Harness for interacting with a standard Angular Material sort header in tests. */
1414
export class MatSortHeaderHarness extends ComponentHarness {
1515
static hostSelector = '.mat-sort-header';
16-
private _button = this.locatorFor('.mat-sort-header-button');
16+
private _container = this.locatorFor('.mat-sort-header-container');
1717

1818
/**
1919
* Gets a `HarnessPredicate` that can be used to
@@ -30,7 +30,7 @@ export class MatSortHeaderHarness extends ComponentHarness {
3030

3131
/** Gets the label of the sort header. */
3232
async getLabel(): Promise<string> {
33-
return (await this._button()).text();
33+
return (await this._container()).text();
3434
}
3535

3636
/** Gets the sorting direction of the header. */
@@ -47,9 +47,13 @@ export class MatSortHeaderHarness extends ComponentHarness {
4747
return '';
4848
}
4949

50-
/** Gets the aria-label of the sort header. */
50+
/**
51+
* Gets the aria-label of the sort header.
52+
* @deprecated The sort header no longer has an `aria-label`. This method will be removed.
53+
* @breaking-change 11.0.0
54+
*/
5155
async getAriaLabel(): Promise<string|null> {
52-
return (await this._button()).getAttribute('aria-label');
56+
return (await this._container()).getAttribute('aria-label');
5357
}
5458

5559
/** Gets whether the sort header is currently being sorted by. */
@@ -59,8 +63,7 @@ export class MatSortHeaderHarness extends ComponentHarness {
5963

6064
/** Whether the sort header is disabled. */
6165
async isDisabled(): Promise<boolean> {
62-
const button = await this._button();
63-
return (await button.getAttribute('disabled')) != null;
66+
return (await this.host()).hasClass('mat-sort-header-disabled');
6467
}
6568

6669
/** Clicks the header to change its sorting direction. Only works if the header is enabled. */

0 commit comments

Comments
 (0)