From 320b2c94fe4571fd8884322c4b634d5c43ec3b6d Mon Sep 17 00:00:00 2001 From: Sloan Haywood Date: Wed, 2 Sep 2020 16:48:52 -0700 Subject: [PATCH] fix(material/snack-bar): flaky screen reader announcements for NVDA/JAWS 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. --- .../mdc-snack-bar/snack-bar-container.html | 8 +- .../mdc-snack-bar/snack-bar-container.ts | 67 ++++++++++++-- .../mdc-snack-bar/snack-bar.spec.ts | 87 ++++++++++++++++--- .../mdc-snack-bar/testing/BUILD.bazel | 1 + .../testing/snack-bar-harness.ts | 12 +++ .../snack-bar/snack-bar-container.html | 8 +- src/material/snack-bar/snack-bar-container.ts | 66 ++++++++++++-- src/material/snack-bar/snack-bar.spec.ts | 86 +++++++++++++++--- src/material/snack-bar/snack-bar.ts | 11 ++- src/material/snack-bar/testing/BUILD.bazel | 1 + src/material/snack-bar/testing/shared.spec.ts | 19 +++- .../snack-bar/testing/snack-bar-harness.ts | 12 +++ .../public_api_guard/material/snack-bar.d.ts | 6 +- .../material/snack-bar/testing.d.ts | 1 + 14 files changed, 333 insertions(+), 52 deletions(-) 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;