Skip to content

Commit be3bf63

Browse files
haywoodsloanwagnermaciel
authored andcommitted
fix(material/snack-bar): flaky screen reader announcements for NVDA/JAWS (angular#20487)
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, 150ms 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 3dec957 commit be3bf63

File tree

14 files changed

+333
-52
lines changed

14 files changed

+333
-52
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 aria-hidden="true">
9+
<ng-template cdkPortalOutlet></ng-template>
10+
</div>
11+
12+
<!-- Will receive the snack bar content from the non-live div, move will happen a short delay after opening -->
13+
<div [attr.aria-live]="_live"></div>
814
</div>
915
</div>

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

Lines changed: 58 additions & 9 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,
@@ -19,6 +20,7 @@ import {
1920
ComponentRef,
2021
ElementRef,
2122
EmbeddedViewRef,
23+
NgZone,
2224
OnDestroy,
2325
ViewChild,
2426
ViewEncapsulation
@@ -49,7 +51,6 @@ const MDC_SNACKBAR_LABEL_CLASS = 'mdc-snackbar__label';
4951
changeDetection: ChangeDetectionStrategy.Default,
5052
encapsulation: ViewEncapsulation.None,
5153
host: {
52-
'[attr.role]': '_role',
5354
'class': 'mdc-snackbar mat-mdc-snack-bar-container',
5455
'[class.mat-snack-bar-container]': 'false',
5556
// Mark this element with a 'mat-exit' attribute to indicate that the snackbar has
@@ -60,14 +61,23 @@ const MDC_SNACKBAR_LABEL_CLASS = 'mdc-snackbar__label';
6061
})
6162
export class MatSnackBarContainer extends BasePortalOutlet
6263
implements _SnackBarContainer, AfterViewChecked, OnDestroy {
64+
/** The number of milliseconds to wait before announcing the snack bar's content. */
65+
private readonly _announceDelay: number = 150;
66+
67+
/** The timeout for announcing the snack bar's content. */
68+
private _announceTimeoutId: number;
69+
70+
/** Subject for notifying that the snack bar has announced to screen readers. */
71+
readonly _onAnnounce: Subject<void> = new Subject();
72+
6373
/** Subject for notifying that the snack bar has exited from view. */
6474
readonly _onExit: Subject<void> = new Subject();
6575

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

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

7282
/** Whether the snack bar is currently exiting. */
7383
_exiting = false;
@@ -103,17 +113,18 @@ export class MatSnackBarContainer extends BasePortalOutlet
103113
constructor(
104114
private _elementRef: ElementRef<HTMLElement>,
105115
public snackBarConfig: MatSnackBarConfig,
106-
private _platform: Platform) {
116+
private _platform: Platform,
117+
private _ngZone: NgZone) {
107118
super();
108119

109-
// Based on the ARIA spec, `alert` and `status` roles have an
110-
// implicit `assertive` and `polite` politeness respectively.
120+
// Use aria-live rather than a live role like 'alert' or 'status'
121+
// because NVDA and JAWS have show inconsistent behavior with live roles.
111122
if (snackBarConfig.politeness === 'assertive' && !snackBarConfig.announcementMessage) {
112-
this._role = 'alert';
123+
this._live = 'assertive';
113124
} else if (snackBarConfig.politeness === 'off') {
114-
this._role = null;
125+
this._live = 'off';
115126
} else {
116-
this._role = 'status';
127+
this._live = 'polite';
117128
}
118129

119130
// `MatSnackBar` will use the config's timeout to determine when the snack bar should be closed.
@@ -141,12 +152,18 @@ export class MatSnackBarContainer extends BasePortalOutlet
141152
// MDC uses some browser APIs that will throw during server-side rendering.
142153
if (this._platform.isBrowser) {
143154
this._mdcFoundation.open();
155+
this._screenReaderAnnounce();
144156
}
145157
}
146158

147159
exit(): Observable<void> {
148160
this._exiting = true;
149161
this._mdcFoundation.close();
162+
163+
// If the snack bar hasn't been announced by the time it exits it wouldn't have been open
164+
// long enough to visually read it either, so clear the timeout for announcing.
165+
clearTimeout(this._announceTimeoutId);
166+
150167
return this._onExit;
151168
}
152169

@@ -188,4 +205,36 @@ export class MatSnackBarContainer extends BasePortalOutlet
188205
throw Error('Attempting to attach snack bar content after content is already attached');
189206
}
190207
}
208+
209+
/**
210+
* Starts a timeout to move the snack bar content to the live region so screen readers will
211+
* announce it.
212+
*/
213+
private _screenReaderAnnounce() {
214+
if (!this._announceTimeoutId) {
215+
this._ngZone.runOutsideAngular(() => {
216+
this._announceTimeoutId = setTimeout(() => {
217+
const inertElement = this._elementRef.nativeElement.querySelector('[aria-hidden]');
218+
const liveElement = this._elementRef.nativeElement.querySelector('[aria-live]');
219+
220+
if (inertElement && liveElement) {
221+
// If an element in the snack bar content is focused before being moved
222+
// track it and restore focus after moving to the live region.
223+
let focusedElement: HTMLElement | null = null;
224+
if (document.activeElement instanceof HTMLElement &&
225+
inertElement.contains(document.activeElement)) {
226+
focusedElement = document.activeElement;
227+
}
228+
229+
inertElement.removeAttribute('aria-hidden');
230+
liveElement.appendChild(inertElement);
231+
focusedElement?.focus();
232+
233+
this._onAnnounce.next();
234+
this._onAnnounce.complete();
235+
}
236+
}, this._announceDelay);
237+
});
238+
}
239+
}
191240
}

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

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ describe('MatSnackBar', () => {
3434
let simpleMessage = 'Burritos are here!';
3535
let simpleActionLabel = 'pickup';
3636

37+
const announceDelay = 150;
38+
const animationFrameDelay = 16;
39+
3740
beforeEach(fakeAsync(() => {
3841
TestBed.configureTestingModule({
3942
imports: [MatSnackBarModule, SnackBarTestModule, NoopAnimationsModule],
@@ -60,44 +63,102 @@ describe('MatSnackBar', () => {
6063
testViewContainerRef = viewContainerFixture.componentInstance.childViewContainer;
6164
});
6265

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

68120
viewContainerFixture.detectChanges();
69121

70122
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"');
123+
const liveElement = containerElement.querySelector('[aria-live]')!;
124+
125+
expect(liveElement.getAttribute('aria-live')).toBe('assertive',
126+
'Expected snack bar container live region to have aria-live="assertive"');
73127
});
74128

75-
it('should have the role of `status` with an `assertive` politeness if an announcement message ' +
76-
'is provided', () => {
129+
it('should have aria-live of `polite` with an `assertive` politeness if an announcement ' +
130+
'message is provided', () => {
77131
snackBar.openFromComponent(BurritosNotification,
78132
{announcementMessage: 'Yay Burritos', politeness: 'assertive'});
79133
viewContainerFixture.detectChanges();
80134

81135
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"');
136+
const liveElement = containerElement.querySelector('[aria-live]')!;
137+
138+
expect(liveElement.getAttribute('aria-live'))
139+
.toBe('polite', 'Expected snack bar container live region to have aria-live="polite"');
84140
});
85141

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

90146
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"');
147+
const liveElement = containerElement.querySelector('[aria-live]')!;
148+
149+
expect(liveElement.getAttribute('aria-live'))
150+
.toBe('polite', 'Expected snack bar container live region to have aria-live="polite"');
93151
});
94152

95153
it('should remove the role if the politeness is turned off', () => {
96154
snackBar.openFromComponent(BurritosNotification, {politeness: 'off'});
97155
viewContainerFixture.detectChanges();
98156

99157
const containerElement = overlayContainerElement.querySelector('mat-mdc-snack-bar-container')!;
100-
expect(containerElement.getAttribute('role')).toBeFalsy('Expected role to be removed');
158+
const liveElement = containerElement.querySelector('[aria-live]')!;
159+
160+
expect(liveElement.getAttribute('aria-live'))
161+
.toBe('off', 'Expected snack bar container live region to have aria-live="off"');
101162
});
102163

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

198259
snackBar.open(simpleMessage, undefined, {announcementMessage: simpleMessage});
199260
viewContainerFixture.detectChanges();
261+
flush();
200262

201263
expect(overlayContainerElement.childElementCount)
202264
.toBe(1, 'Expected the overlay with the default announcement message to be added');
@@ -212,6 +274,7 @@ describe('MatSnackBar', () => {
212274
politeness: 'assertive'
213275
});
214276
viewContainerFixture.detectChanges();
277+
flush();
215278

216279
expect(overlayContainerElement.childElementCount)
217280
.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: 12 additions & 0 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('[aria-live]');
2325
private _simpleSnackBarMessage =
2426
this.locatorFor('.mat-mdc-simple-snack-bar .mat-mdc-snack-bar-label');
2527
private _simpleSnackBarActionButton =
@@ -38,11 +40,21 @@ export class MatSnackBarHarness extends ComponentHarness {
3840
/**
3941
* Gets the role of the snack-bar. The role of a snack-bar is determined based
4042
* on the ARIA politeness specified in the snack-bar config.
43+
* @deprecated @breaking-change 13.0.0 Use `getAriaLive` instead.
4144
*/
4245
async getRole(): Promise<'alert'|'status'|null> {
4346
return (await this.host()).getAttribute('role') as Promise<'alert'|'status'|null>;
4447
}
4548

49+
/**
50+
* Gets the aria-live of the snack-bar's live region. The aria-live of a snack-bar is
51+
* determined based on the ARIA politeness specified in the snack-bar config.
52+
*/
53+
async getAriaLive(): Promise<AriaLivePoliteness> {
54+
return (await this._simpleSnackBarLiveRegion())
55+
.getAttribute('aria-live') as Promise<AriaLivePoliteness>;
56+
}
57+
4658
/**
4759
* Whether the snack-bar has an action. Method cannot be used for snack-bar's with custom content.
4860
*/
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 aria-hidden="true">
3+
<ng-template cdkPortalOutlet></ng-template>
4+
</div>
5+
6+
<!-- Will receive the snack bar content from the non-live div, move will happen a short delay after opening -->
7+
<div [attr.aria-live]="_live"></div>

0 commit comments

Comments
 (0)