Skip to content

Commit 3929ef1

Browse files
author
Sloan Haywood
committed
fix(material/snack-bar): flaky screen reader announcements for NVDA/JAWSFixes 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 liveregion, and content was added to it in the same operation. Some screen readers require the liveregion to be added to the DOM, some time to pass, then content can be added. Now the snack barcontent 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 afteropening. Also, no longer using the alert or status roles. Instead just using aria-live as testingshowed 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-liverather than the alert and status roles.
1 parent f951bf4 commit 3929ef1

File tree

13 files changed

+324
-46
lines changed

13 files changed

+324
-46
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: 56 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,
@@ -27,6 +28,7 @@ import {MatSnackBarConfig, _SnackBarContainer} from '@angular/material/snack-bar
2728
import {MDCSnackbarAdapter, MDCSnackbarFoundation} from '@material/snackbar';
2829
import {Platform} from '@angular/cdk/platform';
2930
import {Observable, Subject} from 'rxjs';
31+
import {take} from 'rxjs/operators';
3032

3133
/**
3234
* The MDC label class that should wrap the label content of the snack bar.
@@ -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,17 @@ const MDC_SNACKBAR_LABEL_CLASS = 'mdc-snackbar__label';
6061
})
6162
export class MatSnackBarContainer extends BasePortalOutlet
6263
implements _SnackBarContainer, AfterViewChecked, OnDestroy {
64+
/** Subject for notifying that the snack bar has announced to screen readers. */
65+
readonly _onAnnounce: Subject<void> = new Subject();
66+
6367
/** Subject for notifying that the snack bar has exited from view. */
6468
readonly _onExit: Subject<void> = new Subject();
6569

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

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

7276
/** Whether the snack bar is currently exiting. */
7377
_exiting = false;
@@ -100,25 +104,38 @@ export class MatSnackBarContainer extends BasePortalOutlet
100104
*/
101105
@ViewChild('label', {static: true}) _label: ElementRef;
102106

107+
/**
108+
* The div container that is hidden from screen readers and initially holds to snack bar
109+
* content.
110+
*/
111+
@ViewChild('nonLive') _nonLiveElementRef: ElementRef<HTMLElement>;
112+
113+
/** The div container that is a live region and will have the snack bar content move into. */
114+
@ViewChild('live') _liveElementRef: ElementRef<HTMLElement>;
115+
103116
constructor(
104117
private _elementRef: ElementRef<HTMLElement>,
105118
public snackBarConfig: MatSnackBarConfig,
106119
private _platform: Platform) {
107120
super();
108121

109-
// Based on the ARIA spec, `alert` and `status` roles have an
110-
// implicit `assertive` and `polite` politeness respectively.
122+
// Use aria-live rather than a live role like 'alert' or 'status'
123+
// because NVDA and JAWS have show inconsitent behavior with live roles.
111124
if (snackBarConfig.politeness === 'assertive' && !snackBarConfig.announcementMessage) {
112-
this._role = 'alert';
125+
this._live = 'assertive';
113126
} else if (snackBarConfig.politeness === 'off') {
114-
this._role = null;
127+
this._live = 'off';
115128
} else {
116-
this._role = 'status';
129+
this._live = 'polite';
117130
}
118131

119132
// `MatSnackBar` will use the config's timeout to determine when the snack bar should be closed.
120133
// Set this to `-1` to mark it as indefinitely open so that MDC does not close itself.
121134
this._mdcFoundation.setTimeoutMs(-1);
135+
136+
// Once the snack bar's entrance animation ends do the DOM manipulation to announce the
137+
// message.
138+
this._onEnter.pipe(take(1)).subscribe(() => this._screenReaderAnnounce());
122139
}
123140

124141
ngAfterViewChecked() {
@@ -188,4 +205,35 @@ 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._nonLiveElementRef && this._liveElementRef) {
215+
const nonLiveEle: HTMLElement = this._nonLiveElementRef.nativeElement;
216+
const liveEle: HTMLElement = this._liveElementRef.nativeElement;
217+
218+
// If an element in the snack bar content is focused before being moved
219+
// track it and restore focus after moving to the live region.
220+
let previouslyFocusedEle: HTMLElement | null = null;
221+
if (document.activeElement instanceof HTMLElement &&
222+
nonLiveEle.contains(document.activeElement)) {
223+
previouslyFocusedEle = document.activeElement;
224+
}
225+
226+
const nodes = Array.from(nonLiveEle.childNodes);
227+
for (const node of nodes) {
228+
liveEle.appendChild(node);
229+
}
230+
231+
if (previouslyFocusedEle) {
232+
previouslyFocusedEle.focus();
233+
}
234+
235+
this._onAnnounce.next();
236+
this._onAnnounce.complete();
237+
}
238+
}
191239
}

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

Lines changed: 76 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+
/** The tick count to reach the next animation frame when using `requestAnimationFrame` */
38+
const animationFrameDelay = 16;
39+
3740
beforeEach(fakeAsync(() => {
3841
TestBed.configureTestingModule({
3942
imports: [MatSnackBarModule, SnackBarTestModule, NoopAnimationsModule],
@@ -60,44 +63,101 @@ 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 non-live region', () => {
67+
snackBar.open('Snack time!', 'Chew');
68+
viewContainerFixture.detectChanges();
69+
70+
const containerElement = overlayContainerElement.querySelector('mat-mdc-snack-bar-container')!;
71+
const nonLiveElement = containerElement.querySelector('.mat-mdc-snack-bar-container-non-live')!;
72+
73+
expect(nonLiveElement.getAttribute('aria-hidden'))
74+
.toBe('true', 'Expected the non-live region to be aria-hidden');
75+
expect(nonLiveElement.textContent).toContain('Snack time!',
76+
'Expected non-live region to contain the snack bar content');
77+
78+
const liveElement = containerElement.querySelector('.mat-mdc-snack-bar-container-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 100ms', 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('.mat-mdc-snack-bar-container-live')!;
89+
tick(animationFrameDelay);
90+
flush();
91+
92+
expect(liveElement.textContent).toContain('Snack time!',
93+
'Expected live region to contain the snack bar content');
94+
95+
const nonLiveElement = containerElement.querySelector('.mat-mdc-snack-bar-container-non-live')!;
96+
expect(nonLiveElement.childNodes.length)
97+
.toBe(0, 'Expected non-live region to not contain any content');
98+
}));
99+
100+
it('should preserve focus when moving content to the live region', fakeAsync(() => {
101+
snackBar.open('Snack time!', 'Chew');
102+
viewContainerFixture.detectChanges();
103+
104+
const actionButton = overlayContainerElement
105+
.querySelector('.mat-mdc-simple-snack-bar .mat-mdc-snack-bar-action')! as HTMLElement;
106+
actionButton.focus();
107+
tick(animationFrameDelay);
108+
flush();
109+
110+
expect(document.activeElement)
111+
.toBe(actionButton, 'Expected the focus to remain on the action button');
112+
}));
113+
114+
it('should have aria-live of `assertive` with an `assertive` politeness if no announcement ' +
115+
'message is provided', () => {
65116
snackBar.openFromComponent(BurritosNotification,
66117
{announcementMessage: '', politeness: 'assertive'});
67118

68119
viewContainerFixture.detectChanges();
69120

70121
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"');
122+
const liveElement = containerElement.querySelector('.mat-mdc-snack-bar-container-live')!;
123+
124+
expect(liveElement.getAttribute('aria-live')).toBe('assertive',
125+
'Expected snack bar container live region to have aria-live="assertive"');
73126
});
74127

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

81134
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"');
135+
const liveElement = containerElement.querySelector('.mat-mdc-snack-bar-container-live')!;
136+
137+
expect(liveElement.getAttribute('aria-live'))
138+
.toBe('polite', 'Expected snack bar container live region to have aria-live="polite"');
84139
});
85140

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

90145
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"');
146+
const liveElement = containerElement.querySelector('.mat-mdc-snack-bar-container-live')!;
147+
148+
expect(liveElement.getAttribute('aria-live'))
149+
.toBe('polite', 'Expected snack bar container live region to have aria-live="polite"');
93150
});
94151

95152
it('should remove the role if the politeness is turned off', () => {
96153
snackBar.openFromComponent(BurritosNotification, {politeness: 'off'});
97154
viewContainerFixture.detectChanges();
98155

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

103163
it('should have exactly one MDC label element when opened through simple snack bar', () => {
@@ -197,6 +257,8 @@ describe('MatSnackBar', () => {
197257

198258
snackBar.open(simpleMessage, undefined, {announcementMessage: simpleMessage});
199259
viewContainerFixture.detectChanges();
260+
tick(animationFrameDelay);
261+
flush();
200262

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

216280
expect(overlayContainerElement.childElementCount)
217281
.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('.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 =
@@ -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 11.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 #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)