Skip to content

Commit f70b92b

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 70ead0c commit f70b92b

File tree

14 files changed

+323
-51
lines changed

14 files changed

+323
-51
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 recieve 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: 53 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+
/** The number of miliseconds to wait before announcing the snack bar's content. */
64+
private readonly _announceDealy: number = 100;
65+
66+
/** The timeout for announcing the snack bar's content. */
67+
private _announceTimeoutId: number;
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;
@@ -106,14 +115,14 @@ export class MatSnackBarContainer extends BasePortalOutlet
106115
private _platform: Platform) {
107116
super();
108117

109-
// Based on the ARIA spec, `alert` and `status` roles have an
110-
// implicit `assertive` and `polite` politeness respectively.
118+
// Use aria-live rather than a live role like 'alert' or 'status'
119+
// because NVDA and JAWS have show inconsitent behavior with live roles.
111120
if (snackBarConfig.politeness === 'assertive' && !snackBarConfig.announcementMessage) {
112-
this._role = 'alert';
121+
this._live = 'assertive';
113122
} else if (snackBarConfig.politeness === 'off') {
114-
this._role = null;
123+
this._live = 'off';
115124
} else {
116-
this._role = 'status';
125+
this._live = 'polite';
117126
}
118127

119128
// `MatSnackBar` will use the config's timeout to determine when the snack bar should be closed.
@@ -141,12 +150,18 @@ export class MatSnackBarContainer extends BasePortalOutlet
141150
// MDC uses some browser APIs that will throw during server-side rendering.
142151
if (this._platform.isBrowser) {
143152
this._mdcFoundation.open();
153+
this._screenReaderAnnounce();
144154
}
145155
}
146156

147157
exit(): Observable<void> {
148158
this._exiting = true;
149159
this._mdcFoundation.close();
160+
161+
// If the snack bar hasn't been announced by the time it exits it wouldn't have been open
162+
// long enough to visually read it either, so clear the timeout for announcing.
163+
clearTimeout(this._announceTimeoutId);
164+
150165
return this._onExit;
151166
}
152167

@@ -188,4 +203,34 @@ export class MatSnackBarContainer extends BasePortalOutlet
188203
throw Error('Attempting to attach snack bar content after content is already attached');
189204
}
190205
}
206+
207+
/**
208+
* Starts a timeout to move the snack bar content to the live region so screen readers will
209+
* announce it.
210+
*/
211+
private _screenReaderAnnounce() {
212+
if (!this._announceTimeoutId) {
213+
this._announceTimeoutId = setTimeout(() => {
214+
const inertElement = this._elementRef.nativeElement.querySelector('[aria-hidden]');
215+
const liveElement = this._elementRef.nativeElement.querySelector('[aria-live]');
216+
217+
if (inertElement && liveElement) {
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 focusedElement: HTMLElement | null = null;
221+
if (document.activeElement instanceof HTMLElement &&
222+
inertElement.contains(document.activeElement)) {
223+
focusedElement = document.activeElement;
224+
}
225+
226+
inertElement.removeAttribute('aria-hidden');
227+
liveElement.appendChild(inertElement);
228+
focusedElement?.focus();
229+
230+
this._onAnnounce.next();
231+
this._onAnnounce.complete();
232+
}
233+
}, this._announceDealy);
234+
}
235+
}
191236
}

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

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

37+
const announceDelay = 100;
38+
3739
beforeEach(fakeAsync(() => {
3840
TestBed.configureTestingModule({
3941
imports: [MatSnackBarModule, SnackBarTestModule, NoopAnimationsModule],
@@ -60,44 +62,99 @@ describe('MatSnackBar', () => {
6062
testViewContainerRef = viewContainerFixture.componentInstance.childViewContainer;
6163
});
6264

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