Skip to content

fix(material/snack-bar): flaky screen reader announcements for NVDA/JAWS #20487

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 23, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
the attached template/component does not contain it.
-->
<div class="mat-mdc-snack-bar-label" #label>
<ng-template cdkPortalOutlet></ng-template>
<!-- Initialy holds the snack bar content, will be empty after announcing to screen readers. -->
<div aria-hidden="true">
<ng-template cdkPortalOutlet></ng-template>
</div>

<!-- Will receive the snack bar content from the non-live div, move will happen a short delay after opening -->
<div [attr.aria-live]="_live"></div>
</div>
</div>
67 changes: 58 additions & 9 deletions src/material-experimental/mdc-snack-bar/snack-bar-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {AriaLivePoliteness} from '@angular/cdk/a11y';
import {
BasePortalOutlet,
CdkPortalOutlet,
Expand All @@ -19,6 +20,7 @@ import {
ComponentRef,
ElementRef,
EmbeddedViewRef,
NgZone,
OnDestroy,
ViewChild,
ViewEncapsulation
Expand Down Expand Up @@ -49,7 +51,6 @@ const MDC_SNACKBAR_LABEL_CLASS = 'mdc-snackbar__label';
changeDetection: ChangeDetectionStrategy.Default,
encapsulation: ViewEncapsulation.None,
host: {
'[attr.role]': '_role',
'class': 'mdc-snackbar mat-mdc-snack-bar-container',
'[class.mat-snack-bar-container]': 'false',
// Mark this element with a 'mat-exit' attribute to indicate that the snackbar has
Expand All @@ -60,14 +61,23 @@ const MDC_SNACKBAR_LABEL_CLASS = 'mdc-snackbar__label';
})
export class MatSnackBarContainer extends BasePortalOutlet
implements _SnackBarContainer, AfterViewChecked, OnDestroy {
/** The number of milliseconds to wait before announcing the snack bar's content. */
private readonly _announceDelay: number = 150;

/** The timeout for announcing the snack bar's content. */
private _announceTimeoutId: number;

/** Subject for notifying that the snack bar has announced to screen readers. */
readonly _onAnnounce: Subject<void> = new Subject();

/** Subject for notifying that the snack bar has exited from view. */
readonly _onExit: Subject<void> = new Subject();

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

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

/** Whether the snack bar is currently exiting. */
_exiting = false;
Expand Down Expand Up @@ -103,17 +113,18 @@ export class MatSnackBarContainer extends BasePortalOutlet
constructor(
private _elementRef: ElementRef<HTMLElement>,
public snackBarConfig: MatSnackBarConfig,
private _platform: Platform) {
private _platform: Platform,
private _ngZone: NgZone) {
super();

// Based on the ARIA spec, `alert` and `status` roles have an
// implicit `assertive` and `polite` politeness respectively.
// Use aria-live rather than a live role like 'alert' or 'status'
// because NVDA and JAWS have show inconsistent behavior with live roles.
if (snackBarConfig.politeness === 'assertive' && !snackBarConfig.announcementMessage) {
this._role = 'alert';
this._live = 'assertive';
} else if (snackBarConfig.politeness === 'off') {
this._role = null;
this._live = 'off';
} else {
this._role = 'status';
this._live = 'polite';
}

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

exit(): Observable<void> {
this._exiting = true;
this._mdcFoundation.close();

// If the snack bar hasn't been announced by the time it exits it wouldn't have been open
// long enough to visually read it either, so clear the timeout for announcing.
clearTimeout(this._announceTimeoutId);

return this._onExit;
}

Expand Down Expand Up @@ -188,4 +205,36 @@ export class MatSnackBarContainer extends BasePortalOutlet
throw Error('Attempting to attach snack bar content after content is already attached');
}
}

/**
* Starts a timeout to move the snack bar content to the live region so screen readers will
* announce it.
*/
private _screenReaderAnnounce() {
if (!this._announceTimeoutId) {
this._ngZone.runOutsideAngular(() => {
this._announceTimeoutId = setTimeout(() => {
const inertElement = this._elementRef.nativeElement.querySelector('[aria-hidden]');
const liveElement = this._elementRef.nativeElement.querySelector('[aria-live]');

if (inertElement && liveElement) {
// If an element in the snack bar content is focused before being moved
// track it and restore focus after moving to the live region.
let focusedElement: HTMLElement | null = null;
if (document.activeElement instanceof HTMLElement &&
inertElement.contains(document.activeElement)) {
focusedElement = document.activeElement;
}

inertElement.removeAttribute('aria-hidden');
liveElement.appendChild(inertElement);
focusedElement?.focus();

this._onAnnounce.next();
this._onAnnounce.complete();
}
}, this._announceDelay);
});
}
}
}
87 changes: 75 additions & 12 deletions src/material-experimental/mdc-snack-bar/snack-bar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ describe('MatSnackBar', () => {
let simpleMessage = 'Burritos are here!';
let simpleActionLabel = 'pickup';

const announceDelay = 150;
const animationFrameDelay = 16;

beforeEach(fakeAsync(() => {
TestBed.configureTestingModule({
imports: [MatSnackBarModule, SnackBarTestModule, NoopAnimationsModule],
Expand All @@ -60,44 +63,102 @@ describe('MatSnackBar', () => {
testViewContainerRef = viewContainerFixture.componentInstance.childViewContainer;
});

it('should have the role of `alert` with an `assertive` politeness if no announcement message ' +
'is provided', () => {
it('should open with content first in the inert region', () => {
snackBar.open('Snack time!', 'Chew');
viewContainerFixture.detectChanges();

const containerElement = overlayContainerElement.querySelector('mat-mdc-snack-bar-container')!;
const inertElement = containerElement.querySelector('[aria-hidden]')!;

expect(inertElement.getAttribute('aria-hidden'))
.toBe('true', 'Expected the non-live region to be aria-hidden');
expect(inertElement.textContent).toContain('Snack time!',
'Expected non-live region to contain the snack bar content');

const liveElement = containerElement.querySelector('[aria-live]')!;
expect(liveElement.childNodes.length)
.toBe(0, 'Expected live region to not contain any content');
});

it('should move content to the live region after 150ms', fakeAsync(() => {
snackBar.open('Snack time!', 'Chew');
viewContainerFixture.detectChanges();

const containerElement = overlayContainerElement.querySelector('mat-mdc-snack-bar-container')!;
const liveElement = containerElement.querySelector('[aria-live]')!;
tick(announceDelay);

expect(liveElement.textContent).toContain('Snack time!',
'Expected live region to contain the snack bar content');

const inertElement = containerElement.querySelector('[aria-hidden]')!;
expect(inertElement).toBeFalsy('Expected non-live region to not contain any content');
flush();
}));

it('should preserve focus when moving content to the live region', fakeAsync(() => {
snackBar.open('Snack time!', 'Chew');
viewContainerFixture.detectChanges();
tick(animationFrameDelay);

const actionButton = overlayContainerElement
.querySelector('.mat-mdc-simple-snack-bar .mat-mdc-snack-bar-action')! as HTMLElement;
actionButton.focus();
expect(document.activeElement)
.toBe(actionButton, 'Expected the focus to move to the action button');

flush();
expect(document.activeElement)
.toBe(actionButton, 'Expected the focus to remain on the action button');
}));

it('should have aria-live of `assertive` with an `assertive` politeness if no announcement ' +
'message is provided', () => {
snackBar.openFromComponent(BurritosNotification,
{announcementMessage: '', politeness: 'assertive'});

viewContainerFixture.detectChanges();

const containerElement = overlayContainerElement.querySelector('mat-mdc-snack-bar-container')!;
expect(containerElement.getAttribute('role'))
.toBe('alert', 'Expected snack bar container to have role="alert"');
const liveElement = containerElement.querySelector('[aria-live]')!;

expect(liveElement.getAttribute('aria-live')).toBe('assertive',
'Expected snack bar container live region to have aria-live="assertive"');
});

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

const containerElement = overlayContainerElement.querySelector('mat-mdc-snack-bar-container')!;
expect(containerElement.getAttribute('role'))
.toBe('status', 'Expected snack bar container to have role="status"');
const liveElement = containerElement.querySelector('[aria-live]')!;

expect(liveElement.getAttribute('aria-live'))
.toBe('polite', 'Expected snack bar container live region to have aria-live="polite"');
});

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

const containerElement = overlayContainerElement.querySelector('mat-mdc-snack-bar-container')!;
expect(containerElement.getAttribute('role'))
.toBe('status', 'Expected snack bar container to have role="status"');
const liveElement = containerElement.querySelector('[aria-live]')!;

expect(liveElement.getAttribute('aria-live'))
.toBe('polite', 'Expected snack bar container live region to have aria-live="polite"');
});

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

const containerElement = overlayContainerElement.querySelector('mat-mdc-snack-bar-container')!;
expect(containerElement.getAttribute('role')).toBeFalsy('Expected role to be removed');
const liveElement = containerElement.querySelector('[aria-live]')!;

expect(liveElement.getAttribute('aria-live'))
.toBe('off', 'Expected snack bar container live region to have aria-live="off"');
});

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

snackBar.open(simpleMessage, undefined, {announcementMessage: simpleMessage});
viewContainerFixture.detectChanges();
flush();

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

expect(overlayContainerElement.childElementCount)
.toBe(1, 'Expected the overlay with a custom `announcementMessage` to be added');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ ts_library(
),
module_name = "@angular/material-experimental/mdc-snack-bar/testing",
deps = [
"//src/cdk/a11y",
"//src/cdk/testing",
],
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

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

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

private _simpleSnackBar = this.locatorForOptional('.mat-mdc-simple-snack-bar');
private _simpleSnackBarLiveRegion = this.locatorFor('[aria-live]');
private _simpleSnackBarMessage =
this.locatorFor('.mat-mdc-simple-snack-bar .mat-mdc-snack-bar-label');
private _simpleSnackBarActionButton =
Expand All @@ -38,11 +40,21 @@ export class MatSnackBarHarness extends ComponentHarness {
/**
* Gets the role of the snack-bar. The role of a snack-bar is determined based
* on the ARIA politeness specified in the snack-bar config.
* @deprecated @breaking-change 13.0.0 Use `getAriaLive` instead.
*/
async getRole(): Promise<'alert'|'status'|null> {
return (await this.host()).getAttribute('role') as Promise<'alert'|'status'|null>;
}

/**
* Gets the aria-live of the snack-bar's live region. The aria-live of a snack-bar is
* determined based on the ARIA politeness specified in the snack-bar config.
*/
async getAriaLive(): Promise<AriaLivePoliteness> {
return (await this._simpleSnackBarLiveRegion())
.getAttribute('aria-live') as Promise<AriaLivePoliteness>;
}

/**
* Whether the snack-bar has an action. Method cannot be used for snack-bar's with custom content.
*/
Expand Down
8 changes: 7 additions & 1 deletion src/material/snack-bar/snack-bar-container.html
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
<ng-template cdkPortalOutlet></ng-template>
<!-- Initialy holds the snack bar content, will be empty after announcing to screen readers. -->
<div aria-hidden="true">
<ng-template cdkPortalOutlet></ng-template>
</div>

<!-- Will receive the snack bar content from the non-live div, move will happen a short delay after opening -->
<div [attr.aria-live]="_live"></div>
Loading