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;