diff --git a/src/material-experimental/mdc-snack-bar/snack-bar-container.html b/src/material-experimental/mdc-snack-bar/snack-bar-container.html
index 4172dd285f5a..eb5fdb1b7f61 100644
--- a/src/material-experimental/mdc-snack-bar/snack-bar-container.html
+++ b/src/material-experimental/mdc-snack-bar/snack-bar-container.html
@@ -4,6 +4,12 @@
the attached template/component does not contain it.
-->
diff --git a/src/material-experimental/mdc-snack-bar/snack-bar-container.ts b/src/material-experimental/mdc-snack-bar/snack-bar-container.ts
index 6ed19cf3eac8..38cfea15926f 100644
--- a/src/material-experimental/mdc-snack-bar/snack-bar-container.ts
+++ b/src/material-experimental/mdc-snack-bar/snack-bar-container.ts
@@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
+import {AriaLivePoliteness} from '@angular/cdk/a11y';
import {
BasePortalOutlet,
CdkPortalOutlet,
@@ -19,6 +20,7 @@ import {
ComponentRef,
ElementRef,
EmbeddedViewRef,
+ NgZone,
OnDestroy,
ViewChild,
ViewEncapsulation
@@ -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
@@ -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 = new Subject();
+
/** Subject for notifying that the snack bar has exited from view. */
readonly _onExit: Subject = new Subject();
/** Subject for notifying that the snack bar has finished entering the view. */
readonly _onEnter: Subject = 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;
@@ -103,17 +113,18 @@ export class MatSnackBarContainer extends BasePortalOutlet
constructor(
private _elementRef: ElementRef,
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.
@@ -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 {
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;
}
@@ -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);
+ });
+ }
+ }
}
diff --git a/src/material-experimental/mdc-snack-bar/snack-bar.spec.ts b/src/material-experimental/mdc-snack-bar/snack-bar.spec.ts
index 1eddc9b3f1ce..3f9bc05977d9 100644
--- a/src/material-experimental/mdc-snack-bar/snack-bar.spec.ts
+++ b/src/material-experimental/mdc-snack-bar/snack-bar.spec.ts
@@ -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],
@@ -60,36 +63,91 @@ 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', () => {
@@ -97,7 +155,10 @@ describe('MatSnackBar', () => {
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', () => {
@@ -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');
@@ -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');
diff --git a/src/material-experimental/mdc-snack-bar/testing/BUILD.bazel b/src/material-experimental/mdc-snack-bar/testing/BUILD.bazel
index 1b1520d343ee..4041652842a1 100644
--- a/src/material-experimental/mdc-snack-bar/testing/BUILD.bazel
+++ b/src/material-experimental/mdc-snack-bar/testing/BUILD.bazel
@@ -10,6 +10,7 @@ ts_library(
),
module_name = "@angular/material-experimental/mdc-snack-bar/testing",
deps = [
+ "//src/cdk/a11y",
"//src/cdk/testing",
],
)
diff --git a/src/material-experimental/mdc-snack-bar/testing/snack-bar-harness.ts b/src/material-experimental/mdc-snack-bar/testing/snack-bar-harness.ts
index 334a1d3385a8..c65b4c6df41a 100644
--- a/src/material-experimental/mdc-snack-bar/testing/snack-bar-harness.ts
+++ b/src/material-experimental/mdc-snack-bar/testing/snack-bar-harness.ts
@@ -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';
@@ -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 =
@@ -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 {
+ return (await this._simpleSnackBarLiveRegion())
+ .getAttribute('aria-live') as Promise;
+ }
+
/**
* Whether the snack-bar has an action. Method cannot be used for snack-bar's with custom content.
*/
diff --git a/src/material/snack-bar/snack-bar-container.html b/src/material/snack-bar/snack-bar-container.html
index 215f0d9c55b3..32a293bba0c7 100644
--- a/src/material/snack-bar/snack-bar-container.html
+++ b/src/material/snack-bar/snack-bar-container.html
@@ -1 +1,7 @@
-
+
+
+
+
+
+
+
diff --git a/src/material/snack-bar/snack-bar-container.ts b/src/material/snack-bar/snack-bar-container.ts
index d5f8cf707d11..9116587a7b37 100644
--- a/src/material/snack-bar/snack-bar-container.ts
+++ b/src/material/snack-bar/snack-bar-container.ts
@@ -7,6 +7,8 @@
*/
import {AnimationEvent} from '@angular/animations';
+import {AriaLivePoliteness} from '@angular/cdk/a11y';
+import {Platform} from '@angular/cdk/platform';
import {
BasePortalOutlet,
CdkPortalOutlet,
@@ -37,6 +39,7 @@ import {MatSnackBarConfig} from './snack-bar-config';
*/
export interface _SnackBarContainer {
snackBarConfig: MatSnackBarConfig;
+ _onAnnounce: Subject;
_onExit: Subject;
_onEnter: Subject;
enter: () => void;
@@ -61,7 +64,6 @@ export interface _SnackBarContainer {
encapsulation: ViewEncapsulation.None,
animations: [matSnackBarAnimations.snackBarState],
host: {
- '[attr.role]': '_role',
'class': 'mat-snack-bar-container',
'[@state]': '_animationState',
'(@state.done)': 'onAnimationEnd($event)'
@@ -69,12 +71,21 @@ export interface _SnackBarContainer {
})
export class MatSnackBarContainer extends BasePortalOutlet
implements OnDestroy, _SnackBarContainer {
+ /** 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;
+
/** Whether the component has been destroyed. */
private _destroyed = false;
/** The portal outlet inside of this container into which the snack bar content will be loaded. */
@ViewChild(CdkPortalOutlet, {static: true}) _portalOutlet: CdkPortalOutlet;
+ /** Subject for notifying that the snack bar has announced to screen readers. */
+ readonly _onAnnounce: Subject = new Subject();
+
/** Subject for notifying that the snack bar has exited from view. */
readonly _onExit: Subject = new Subject();
@@ -84,26 +95,27 @@ export class MatSnackBarContainer extends BasePortalOutlet
/** The state of the snack bar animations. */
_animationState = 'void';
- /** ARIA role for the snack bar container. */
- _role: 'alert' | 'status' | null;
+ /** aria-live value for the live region. */
+ _live: AriaLivePoliteness;
constructor(
private _ngZone: NgZone,
private _elementRef: ElementRef,
private _changeDetectorRef: ChangeDetectorRef,
+ private _platform: Platform,
/** The snack bar configuration. */
public snackBarConfig: MatSnackBarConfig) {
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';
}
}
@@ -157,6 +169,7 @@ export class MatSnackBarContainer extends BasePortalOutlet
if (!this._destroyed) {
this._animationState = 'visible';
this._changeDetectorRef.detectChanges();
+ this._screenReaderAnnounce();
}
}
@@ -172,6 +185,10 @@ export class MatSnackBarContainer extends BasePortalOutlet
// test harness.
this._elementRef.nativeElement.setAttribute('mat-exit', '');
+ // 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;
}
@@ -221,4 +238,37 @@ 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 (this._platform.isBrowser &&
+ 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);
+ });
+ }
+ }
}
diff --git a/src/material/snack-bar/snack-bar.spec.ts b/src/material/snack-bar/snack-bar.spec.ts
index 00fd83ddec6a..bb59a7728584 100644
--- a/src/material/snack-bar/snack-bar.spec.ts
+++ b/src/material/snack-bar/snack-bar.spec.ts
@@ -35,6 +35,8 @@ describe('MatSnackBar', () => {
let simpleMessage = 'Burritos are here!';
let simpleActionLabel = 'pickup';
+ const announceDelay = 150;
+
beforeEach(fakeAsync(() => {
TestBed.configureTestingModule({
imports: [MatSnackBarModule, SnackBarTestModule, NoopAnimationsModule],
@@ -61,44 +63,100 @@ 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('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('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');
+ }));
+
+ it('should preserve focus when moving content to the live region', fakeAsync(() => {
+ snackBar.open('Snack time!', 'Chew');
+ viewContainerFixture.detectChanges();
+
+ const actionButton = overlayContainerElement
+ .querySelector('.mat-simple-snackbar-action > button')! 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('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('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('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', () => {
+ it('should have aria-live of `off` if the politeness is turned off', () => {
snackBar.openFromComponent(BurritosNotification, {politeness: 'off'});
viewContainerFixture.detectChanges();
const containerElement = overlayContainerElement.querySelector('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 open and close a snackbar without a ViewContainerRef', fakeAsync(() => {
@@ -189,6 +247,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');
@@ -204,6 +263,7 @@ describe('MatSnackBar', () => {
politeness: 'assertive'
});
viewContainerFixture.detectChanges();
+ flush();
expect(overlayContainerElement.childElementCount)
.toBe(1, 'Expected the overlay with a custom `announcementMessage` to be added');
diff --git a/src/material/snack-bar/snack-bar.ts b/src/material/snack-bar/snack-bar.ts
index b56729475908..dde2d8dbafe5 100644
--- a/src/material/snack-bar/snack-bar.ts
+++ b/src/material/snack-bar/snack-bar.ts
@@ -205,6 +205,13 @@ export class MatSnackBar implements OnDestroy {
state.matches ? classList.add(this.handsetCssClass) : classList.remove(this.handsetCssClass);
});
+ if (config.announcementMessage) {
+ // Wait until the snack bar contents have been announced then deliver this message.
+ container._onAnnounce.subscribe(() => {
+ this._live.announce(config.announcementMessage!, config.politeness);
+ });
+ }
+
this._animateSnackBar(snackBarRef, config);
this._openedSnackBarRef = snackBarRef;
return this._openedSnackBarRef;
@@ -240,10 +247,6 @@ export class MatSnackBar implements OnDestroy {
if (config.duration && config.duration > 0) {
snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(config.duration!));
}
-
- if (config.announcementMessage) {
- this._live.announce(config.announcementMessage, config.politeness);
- }
}
/**
diff --git a/src/material/snack-bar/testing/BUILD.bazel b/src/material/snack-bar/testing/BUILD.bazel
index 9bb1a3b81706..2656756520b0 100644
--- a/src/material/snack-bar/testing/BUILD.bazel
+++ b/src/material/snack-bar/testing/BUILD.bazel
@@ -10,6 +10,7 @@ ts_library(
),
module_name = "@angular/material/snack-bar/testing",
deps = [
+ "//src/cdk/a11y",
"//src/cdk/testing",
],
)
diff --git a/src/material/snack-bar/testing/shared.spec.ts b/src/material/snack-bar/testing/shared.spec.ts
index bd63cba3c059..626a951e6926 100644
--- a/src/material/snack-bar/testing/shared.spec.ts
+++ b/src/material/snack-bar/testing/shared.spec.ts
@@ -70,19 +70,34 @@ export function runHarnessTests(
});
it('should be able to get role of snack-bar', async () => {
+ // Get role is now deprecated, so it should always return null.
fixture.componentInstance.openCustom();
let snackBar = await loader.getHarness(snackBarHarness);
- expect(await snackBar.getRole()).toBe('alert');
+ expect(await snackBar.getRole()).toBe(null);
fixture.componentInstance.openCustom({politeness: 'polite'});
snackBar = await loader.getHarness(snackBarHarness);
- expect(await snackBar.getRole()).toBe('status');
+ expect(await snackBar.getRole()).toBe(null);
fixture.componentInstance.openCustom({politeness: 'off'});
snackBar = await loader.getHarness(snackBarHarness);
expect(await snackBar.getRole()).toBe(null);
});
+ it('should be able to get aria-live of snack-bar', async () => {
+ fixture.componentInstance.openCustom();
+ let snackBar = await loader.getHarness(snackBarHarness);
+ expect(await snackBar.getAriaLive()).toBe('assertive');
+
+ fixture.componentInstance.openCustom({politeness: 'polite'});
+ snackBar = await loader.getHarness(snackBarHarness);
+ expect(await snackBar.getAriaLive()).toBe('polite');
+
+ fixture.componentInstance.openCustom({politeness: 'off'});
+ snackBar = await loader.getHarness(snackBarHarness);
+ expect(await snackBar.getAriaLive()).toBe('off');
+ });
+
it('should be able to get message of simple snack-bar', async () => {
fixture.componentInstance.openSimple('Subscribed to newsletter.');
let snackBar = await loader.getHarness(snackBarHarness);
diff --git a/src/material/snack-bar/testing/snack-bar-harness.ts b/src/material/snack-bar/testing/snack-bar-harness.ts
index d80dff543210..c6bb01e8e8e0 100644
--- a/src/material/snack-bar/testing/snack-bar-harness.ts
+++ b/src/material/snack-bar/testing/snack-bar-harness.ts
@@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
+import {AriaLivePoliteness} from '@angular/cdk/a11y';
import {ContentContainerComponentHarness, HarnessPredicate} from '@angular/cdk/testing';
import {SnackBarHarnessFilters} from './snack-bar-harness-filters';
@@ -17,6 +18,7 @@ export class MatSnackBarHarness extends ContentContainerComponentHarness
static hostSelector = '.mat-snack-bar-container';
private _simpleSnackBar = this.locatorForOptional('.mat-simple-snackbar');
+ private _simpleSnackBarLiveRegion = this.locatorFor('[aria-live]');
private _simpleSnackBarMessage = this.locatorFor('.mat-simple-snackbar > span');
private _simpleSnackBarActionButton =
this.locatorForOptional('.mat-simple-snackbar-action > button');
@@ -34,11 +36,21 @@ export class MatSnackBarHarness extends ContentContainerComponentHarness
/**
* 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 {
+ return (await this._simpleSnackBarLiveRegion())
+ .getAttribute('aria-live') as Promise;
+ }
+
/**
* Whether the snack-bar has an action. Method cannot be used for snack-bar's with custom content.
*/
diff --git a/tools/public_api_guard/material/snack-bar.d.ts b/tools/public_api_guard/material/snack-bar.d.ts
index 2985627cf3ce..d878072916a8 100644
--- a/tools/public_api_guard/material/snack-bar.d.ts
+++ b/tools/public_api_guard/material/snack-bar.d.ts
@@ -1,4 +1,5 @@
export interface _SnackBarContainer {
+ _onAnnounce: Subject;
_onEnter: Subject;
_onExit: Subject;
attachComponentPortal: (portal: ComponentPortal) => ComponentRef;
@@ -48,13 +49,14 @@ export declare class MatSnackBarConfig {
export declare class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy, _SnackBarContainer {
_animationState: string;
+ _live: AriaLivePoliteness;
+ readonly _onAnnounce: Subject;
readonly _onEnter: Subject;
readonly _onExit: Subject;
_portalOutlet: CdkPortalOutlet;
- _role: 'alert' | 'status' | null;
attachDomPortal: (portal: DomPortal) => void;
snackBarConfig: MatSnackBarConfig;
- constructor(_ngZone: NgZone, _elementRef: ElementRef, _changeDetectorRef: ChangeDetectorRef,
+ constructor(_ngZone: NgZone, _elementRef: ElementRef, _changeDetectorRef: ChangeDetectorRef, _platform: Platform,
snackBarConfig: MatSnackBarConfig);
attachComponentPortal(portal: ComponentPortal): ComponentRef;
attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef;
diff --git a/tools/public_api_guard/material/snack-bar/testing.d.ts b/tools/public_api_guard/material/snack-bar/testing.d.ts
index 8eb9869acda7..ec99d46dda34 100644
--- a/tools/public_api_guard/material/snack-bar/testing.d.ts
+++ b/tools/public_api_guard/material/snack-bar/testing.d.ts
@@ -1,6 +1,7 @@
export declare class MatSnackBarHarness extends ContentContainerComponentHarness {
dismissWithAction(): Promise;
getActionDescription(): Promise;
+ getAriaLive(): Promise;
getMessage(): Promise;
getRole(): Promise<'alert' | 'status' | null>;
hasAction(): Promise;