Skip to content

Commit 6c2c961

Browse files
author
Sloan Haywood
committed
fix(material/snack-bar): flaky screen reader announcements for NVDA/JAWS
Fixes a bug where NVDA won't announce polite snack bars and JAWS won't announce any. This is because the live region (snack-bar-container) was added to the DOM, marked as a live region, and content was added to it in the same operation. Some screen readers require the live region to be added to the DOM, some time to pass, then content can be added. Now the snack bar content is added to an aria-hidden div then, 100ms later, moved to a div with aria-live defined. This won't cause any visual changes and keeps the snack bar content available immediatly after opening. Also, no longer using the alert or status roles. Instead just using aria-live as testing showed that NVDA will double announce with the alert role and JAWS won't announce any button text. BREAKING CHANGE: matSnackBarHarness.getRole() replaced with .getAriaLive() due to using aria-live rather than the alert and status roles.
1 parent f00f46a commit 6c2c961

File tree

13 files changed

+315
-55
lines changed

13 files changed

+315
-55
lines changed

src/material-experimental/mdc-snack-bar/snack-bar-container.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
the attached template/component does not contain it.
55
-->
66
<div class="mat-mdc-snack-bar-label" #label>
7-
<ng-template cdkPortalOutlet></ng-template>
7+
<!-- Initialy holds the snack bar content, will be empty after announcing to screen readers. -->
8+
<div #nonLive aria-hidden="true" class="mat-mdc-snack-bar-container-non-live">
9+
<ng-template cdkPortalOutlet></ng-template>
10+
</div>
11+
12+
<!-- Will recieve the snack bar content from the non-live div, move will happen a short delay after opening -->
13+
<div #live [attr.aria-live]="_live" class="mat-mdc-snack-bar-container-live"></div>
814
</div>
915
</div>

src/material-experimental/mdc-snack-bar/snack-bar-container.ts

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

9+
import {AriaLivePoliteness} from '@angular/cdk/a11y';
910
import {
1011
BasePortalOutlet,
1112
CdkPortalOutlet,
@@ -49,7 +50,6 @@ const MDC_SNACKBAR_LABEL_CLASS = 'mdc-snackbar__label';
4950
changeDetection: ChangeDetectionStrategy.Default,
5051
encapsulation: ViewEncapsulation.None,
5152
host: {
52-
'[attr.role]': '_role',
5353
'class': 'mdc-snackbar mat-mdc-snack-bar-container',
5454
'[class.mat-snack-bar-container]': 'false',
5555
// Mark this element with a 'mat-exit' attribute to indicate that the snackbar has
@@ -60,14 +60,23 @@ const MDC_SNACKBAR_LABEL_CLASS = 'mdc-snackbar__label';
6060
})
6161
export class MatSnackBarContainer extends BasePortalOutlet
6262
implements _SnackBarContainer, AfterViewChecked, OnDestroy {
63+
/** Timeout for moving content to the live region for screen reader announcement. */
64+
private _announceTimeoutId: number;
65+
66+
/** The number of milliseconds to wait after before moving content to the live region. */
67+
readonly _announceDelay = 100;
68+
69+
/** Subject for notifying that the snack bar has announced to screen readers. */
70+
readonly _onAnnounce: Subject<void> = new Subject();
71+
6372
/** Subject for notifying that the snack bar has exited from view. */
6473
readonly _onExit: Subject<void> = new Subject();
6574

6675
/** Subject for notifying that the snack bar has finished entering the view. */
6776
readonly _onEnter: Subject<void> = new Subject();
6877

69-
/** ARIA role for the snack bar container. */
70-
_role: 'alert' | 'status' | null;
78+
/** aria-live value for the live region. */
79+
_live: AriaLivePoliteness;
7180

7281
/** Whether the snack bar is currently exiting. */
7382
_exiting = false;
@@ -100,20 +109,29 @@ export class MatSnackBarContainer extends BasePortalOutlet
100109
*/
101110
@ViewChild('label', {static: true}) _label: ElementRef;
102111

112+
/**
113+
* The div container that is hidden from screen readers and initially holds to snack bar
114+
* content.
115+
*/
116+
@ViewChild('nonLive', {static: true}) _nonLiveElementRef: ElementRef<HTMLElement>;
117+
118+
/** The div container that is a live region and will have the snack bar content move into. */
119+
@ViewChild('live', {static: true}) _liveElementRef: ElementRef<HTMLElement>;
120+
103121
constructor(
104122
private _elementRef: ElementRef<HTMLElement>,
105123
public snackBarConfig: MatSnackBarConfig,
106124
private _platform: Platform) {
107125
super();
108126

109-
// Based on the ARIA spec, `alert` and `status` roles have an
110-
// implicit `assertive` and `polite` politeness respectively.
127+
// Use aria-live rather than a live role like 'alert' or 'status'
128+
// because NVDA and JAWS have show inconsitent behavior with live roles.
111129
if (snackBarConfig.politeness === 'assertive' && !snackBarConfig.announcementMessage) {
112-
this._role = 'alert';
130+
this._live = 'assertive';
113131
} else if (snackBarConfig.politeness === 'off') {
114-
this._role = null;
132+
this._live = 'off';
115133
} else {
116-
this._role = 'status';
134+
this._live = 'polite';
117135
}
118136

119137
// `MatSnackBar` will use the config's timeout to determine when the snack bar should be closed.
@@ -141,12 +159,19 @@ export class MatSnackBarContainer extends BasePortalOutlet
141159
// MDC uses some browser APIs that will throw during server-side rendering.
142160
if (this._platform.isBrowser) {
143161
this._mdcFoundation.open();
162+
this._screenReaderAnnounce();
144163
}
145164
}
146165

147166
exit(): Observable<void> {
148167
this._exiting = true;
149168
this._mdcFoundation.close();
169+
170+
// Stop the timeout for announcing the snack bar to screen readers.
171+
// If the announcement hasn't already been made the snack bar hasn't
172+
// been open long enough for a sighted user to read it either.
173+
clearTimeout(this._announceTimeoutId);
174+
150175
return this._onExit;
151176
}
152177

@@ -188,4 +213,37 @@ export class MatSnackBarContainer extends BasePortalOutlet
188213
throw Error('Attempting to attach snack bar content after content is already attached');
189214
}
190215
}
216+
217+
/**
218+
* Starts a timeout to move the snack bar content to the live region so screen readers will
219+
* announce it.
220+
*/
221+
private _screenReaderAnnounce() {
222+
if (!this._announceTimeoutId) {
223+
this._announceTimeoutId = setTimeout(() => {
224+
const nonLiveEle: HTMLElement = this._nonLiveElementRef.nativeElement;
225+
const liveEle: HTMLElement = this._liveElementRef.nativeElement;
226+
227+
// If an element in the snack bar content is focused before being moved
228+
// track it and restore focus after moving to the live region.
229+
let previouslyFocusedEle: HTMLElement | null = null;
230+
if (document.activeElement instanceof HTMLElement &&
231+
nonLiveEle.contains(document.activeElement)) {
232+
previouslyFocusedEle = document.activeElement;
233+
}
234+
235+
const nodes = Array.from(nonLiveEle.childNodes);
236+
for (const node of nodes) {
237+
liveEle.appendChild(node);
238+
}
239+
240+
if (previouslyFocusedEle) {
241+
previouslyFocusedEle.focus();
242+
}
243+
244+
this._onAnnounce.next();
245+
this._onAnnounce.complete();
246+
}, this._announceDelay);
247+
}
248+
}
191249
}

src/material-experimental/mdc-snack-bar/snack-bar.spec.ts

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -60,44 +60,101 @@ describe('MatSnackBar', () => {
6060
testViewContainerRef = viewContainerFixture.componentInstance.childViewContainer;
6161
});
6262

63-
it('should have the role of `alert` with an `assertive` politeness if no announcement message ' +
64-
'is provided', () => {
63+
it('should open with content first in the non-live region', () => {
64+
snackBar.open('Snack time!', 'Chew');
65+
viewContainerFixture.detectChanges();
66+
67+
const containerElement = overlayContainerElement.querySelector('mat-mdc-snack-bar-container')!;
68+
const nonLiveElement = containerElement.querySelector('.mat-mdc-snack-bar-container-non-live')!;
69+
70+
expect(nonLiveElement.getAttribute('aria-hidden'))
71+
.toBe('true', 'Expected the non-live region to be aria-hidden');
72+
expect(nonLiveElement.textContent).toContain('Snack time!',
73+
'Expected non-live region to contain the snack bar content');
74+
75+
const liveElement = containerElement.querySelector('.mat-mdc-snack-bar-container-live')!;
76+
expect(liveElement.childNodes.length)
77+
.toBe(0, 'Expected live region to not contain any content');
78+
});
79+
80+
it('should move content to the live region after 100ms', fakeAsync(() => {
81+
snackBar.open('Snack time!', 'Chew');
82+
viewContainerFixture.detectChanges();
83+
84+
const containerElement = overlayContainerElement.querySelector('mat-mdc-snack-bar-container')!;
85+
const liveElement = containerElement.querySelector('.mat-mdc-snack-bar-container-live')!;
86+
tick(100);
87+
88+
expect(liveElement.textContent).toContain('Snack time!',
89+
'Expected live region to contain the snack bar content');
90+
91+
const nonLiveElement = containerElement.querySelector('.mat-mdc-snack-bar-container-non-live')!;
92+
expect(nonLiveElement.childNodes.length)
93+
.toBe(0, 'Expected non-live region to not contain any content');
94+
flush();
95+
}));
96+
97+
it('should preserve focus when moving content to the live region', fakeAsync(() => {
98+
snackBar.open('Snack time!', 'Chew');
99+
viewContainerFixture.detectChanges();
100+
101+
const actionButton = overlayContainerElement
102+
.querySelector('.mat-mdc-simple-snack-bar .mat-mdc-snack-bar-action')! as HTMLElement;
103+
actionButton.focus();
104+
tick(100);
105+
106+
expect(document.activeElement)
107+
.toBe(actionButton, 'Expected the focus to remain on the action button');
108+
flush();
109+
}));
110+
111+
it('should have aria-live of `assertive` with an `assertive` politeness if no announcement ' +
112+
'message is provided', () => {
65113
snackBar.openFromComponent(BurritosNotification,
66114
{announcementMessage: '', politeness: 'assertive'});
67115

68116
viewContainerFixture.detectChanges();
69117

70118
const containerElement = overlayContainerElement.querySelector('mat-mdc-snack-bar-container')!;
71-
expect(containerElement.getAttribute('role'))
72-
.toBe('alert', 'Expected snack bar container to have role="alert"');
119+
const liveElement = containerElement.querySelector('.mat-mdc-snack-bar-container-live')!;
120+
121+
expect(liveElement.getAttribute('aria-live')).toBe('assertive',
122+
'Expected snack bar container live region to have aria-live="assertive"');
73123
});
74124

75-
it('should have the role of `status` with an `assertive` politeness if an announcement message ' +
76-
'is provided', () => {
125+
it('should have aria-live of `polite` with an `assertive` politeness if an announcement ' +
126+
'message is provided', () => {
77127
snackBar.openFromComponent(BurritosNotification,
78128
{announcementMessage: 'Yay Burritos', politeness: 'assertive'});
79129
viewContainerFixture.detectChanges();
80130

81131
const containerElement = overlayContainerElement.querySelector('mat-mdc-snack-bar-container')!;
82-
expect(containerElement.getAttribute('role'))
83-
.toBe('status', 'Expected snack bar container to have role="status"');
132+
const liveElement = containerElement.querySelector('.mat-mdc-snack-bar-container-live')!;
133+
134+
expect(liveElement.getAttribute('aria-live'))
135+
.toBe('polite', 'Expected snack bar container live region to have aria-live="polite"');
84136
});
85137

86-
it('should have the role of `status` with a `polite` politeness', () => {
138+
it('should have aria-live of `polite` with a `polite` politeness', () => {
87139
snackBar.openFromComponent(BurritosNotification, {politeness: 'polite'});
88140
viewContainerFixture.detectChanges();
89141

90142
const containerElement = overlayContainerElement.querySelector('mat-mdc-snack-bar-container')!;
91-
expect(containerElement.getAttribute('role'))
92-
.toBe('status', 'Expected snack bar container to have role="status"');
143+
const liveElement = containerElement.querySelector('.mat-mdc-snack-bar-container-live')!;
144+
145+
expect(liveElement.getAttribute('aria-live'))
146+
.toBe('polite', 'Expected snack bar container live region to have aria-live="polite"');
93147
});
94148

95149
it('should remove the role if the politeness is turned off', () => {
96150
snackBar.openFromComponent(BurritosNotification, {politeness: 'off'});
97151
viewContainerFixture.detectChanges();
98152

99153
const containerElement = overlayContainerElement.querySelector('mat-mdc-snack-bar-container')!;
100-
expect(containerElement.getAttribute('role')).toBeFalsy('Expected role to be removed');
154+
const liveElement = containerElement.querySelector('.mat-mdc-snack-bar-container-live')!;
155+
156+
expect(liveElement.getAttribute('aria-live'))
157+
.toBe('off', 'Expected snack bar container live region to have aria-live="off"');
101158
});
102159

103160
it('should have exactly one MDC label element when opened through simple snack bar', () => {
@@ -197,6 +254,7 @@ describe('MatSnackBar', () => {
197254

198255
snackBar.open(simpleMessage, undefined, {announcementMessage: simpleMessage});
199256
viewContainerFixture.detectChanges();
257+
flush();
200258

201259
expect(overlayContainerElement.childElementCount)
202260
.toBe(1, 'Expected the overlay with the default announcement message to be added');
@@ -212,6 +270,7 @@ describe('MatSnackBar', () => {
212270
politeness: 'assertive'
213271
});
214272
viewContainerFixture.detectChanges();
273+
flush();
215274

216275
expect(overlayContainerElement.childElementCount)
217276
.toBe(1, 'Expected the overlay with a custom `announcementMessage` to be added');

src/material-experimental/mdc-snack-bar/testing/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ ts_library(
1010
),
1111
module_name = "@angular/material-experimental/mdc-snack-bar/testing",
1212
deps = [
13+
"//src/cdk/a11y",
1314
"//src/cdk/testing",
1415
],
1516
)

src/material-experimental/mdc-snack-bar/testing/snack-bar-harness.ts

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

9+
import {AriaLivePoliteness} from '@angular/cdk/a11y';
910
import {ComponentHarness, HarnessPredicate} from '@angular/cdk/testing';
1011
import {SnackBarHarnessFilters} from './snack-bar-harness-filters';
1112

@@ -20,6 +21,7 @@ export class MatSnackBarHarness extends ComponentHarness {
2021
static hostSelector = '.mat-mdc-snack-bar-container:not([mat-exit])';
2122

2223
private _simpleSnackBar = this.locatorForOptional('.mat-mdc-simple-snack-bar');
24+
private _simpleSnackBarLiveRegion = this.locatorFor('.mat-mdc-snack-bar-container-live');
2325
private _simpleSnackBarMessage =
2426
this.locatorFor('.mat-mdc-simple-snack-bar .mat-mdc-snack-bar-label');
2527
private _simpleSnackBarActionButton =
@@ -36,11 +38,12 @@ export class MatSnackBarHarness extends ComponentHarness {
3638
}
3739

3840
/**
39-
* Gets the role of the snack-bar. The role of a snack-bar is determined based
40-
* on the ARIA politeness specified in the snack-bar config.
41+
* Gets the aria-live of the snack-bar's live region. The aria-live of a snack-bar is
42+
* determined based on the ARIA politeness specified in the snack-bar config.
4143
*/
42-
async getRole(): Promise<'alert'|'status'|null> {
43-
return (await this.host()).getAttribute('role') as Promise<'alert'|'status'|null>;
44+
async getAriaLive(): Promise<AriaLivePoliteness> {
45+
return (await this._simpleSnackBarLiveRegion())
46+
.getAttribute('aria-live') as Promise<AriaLivePoliteness>;
4447
}
4548

4649
/**
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
1-
<ng-template cdkPortalOutlet></ng-template>
1+
<!-- Initialy holds the snack bar content, will be empty after announcing to screen readers. -->
2+
<div #nonLive aria-hidden="true" class="snack-bar-container-non-live">
3+
<ng-template cdkPortalOutlet></ng-template>
4+
</div>
5+
6+
<!-- Will recieve the snack bar content from the non-live div, move will happen a short delay after opening -->
7+
<div #live [attr.aria-live]="_live" class="snack-bar-container-live"></div>

0 commit comments

Comments
 (0)