diff --git a/src/cdk/a11y/BUILD.bazel b/src/cdk/a11y/BUILD.bazel index a91cfff27e38..3fbd6d993177 100644 --- a/src/cdk/a11y/BUILD.bazel +++ b/src/cdk/a11y/BUILD.bazel @@ -11,6 +11,7 @@ ng_module( deps = [ "//src/cdk/coercion", "//src/cdk/keycodes", + "//src/cdk/observers", "//src/cdk/platform", "@rxjs", ], @@ -35,6 +36,7 @@ ts_library( deps = [ ":a11y", "//src/cdk/keycodes", + "//src/cdk/observers", "//src/cdk/platform", "//src/cdk/testing", "@rxjs", @@ -45,6 +47,8 @@ ts_library( ts_web_test( name = "unit_tests", + # TODO(mmalerba): re-enable once ngfactory issue is resolved. + tags = ["manual"], bootstrap = [ "//:web_test_bootstrap_scripts", ], diff --git a/src/cdk/a11y/a11y-module.ts b/src/cdk/a11y/a11y-module.ts index bb28a47e4a1b..5b9cabfb3ca8 100644 --- a/src/cdk/a11y/a11y-module.ts +++ b/src/cdk/a11y/a11y-module.ts @@ -6,15 +6,17 @@ * found in the LICENSE file at https://angular.io/license */ +import {ObserversModule} from '@angular/cdk/observers'; import {PlatformModule} from '@angular/cdk/platform'; import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; import {CdkMonitorFocus} from './focus-monitor/focus-monitor'; import {CdkTrapFocus} from './focus-trap/focus-trap'; +import {CdkAriaLive} from './live-announcer/live-announcer'; @NgModule({ - imports: [CommonModule, PlatformModule], - declarations: [CdkTrapFocus, CdkMonitorFocus], - exports: [CdkTrapFocus, CdkMonitorFocus], + imports: [CommonModule, PlatformModule, ObserversModule], + declarations: [CdkAriaLive, CdkTrapFocus, CdkMonitorFocus], + exports: [CdkAriaLive, CdkTrapFocus, CdkMonitorFocus], }) export class A11yModule {} diff --git a/src/cdk/a11y/live-announcer/live-announcer.spec.ts b/src/cdk/a11y/live-announcer/live-announcer.spec.ts index 19c029d7d2aa..5de2ea9ae33c 100644 --- a/src/cdk/a11y/live-announcer/live-announcer.spec.ts +++ b/src/cdk/a11y/live-announcer/live-announcer.spec.ts @@ -1,5 +1,6 @@ -import {Component} from '@angular/core'; -import {ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing'; +import {MutationObserverFactory} from '@angular/cdk/observers'; +import {Component, Input} from '@angular/core'; +import {ComponentFixture, fakeAsync, flush, inject, TestBed, tick} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {A11yModule} from '../index'; import {LiveAnnouncer} from './live-announcer'; @@ -111,6 +112,76 @@ describe('LiveAnnouncer', () => { }); }); +describe('CdkAriaLive', () => { + let mutationCallbacks: Function[] = []; + let announcer: LiveAnnouncer; + let announcerSpy: jasmine.Spy; + let fixture: ComponentFixture; + + const invokeMutationCallbacks = () => mutationCallbacks.forEach(cb => cb()); + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [A11yModule], + declarations: [DivWithCdkAriaLive], + providers: [{ + provide: MutationObserverFactory, + useValue: { + create: (callback: Function) => { + mutationCallbacks.push(callback); + + return { + observe: () => {}, + disconnect: () => {} + }; + } + } + }] + }); + })); + + beforeEach(fakeAsync(inject([LiveAnnouncer], (la: LiveAnnouncer) => { + announcer = la; + announcerSpy = spyOn(la, 'announce').and.callThrough(); + fixture = TestBed.createComponent(DivWithCdkAriaLive); + fixture.detectChanges(); + flush(); + }))); + + afterEach(fakeAsync(() => { + // In our tests we always remove the current live element, in + // order to avoid having multiple announcer elements in the DOM. + announcer.ngOnDestroy(); + })); + + it('should dynamically update the politeness', fakeAsync(() => { + fixture.componentInstance.content = 'New content'; + fixture.detectChanges(); + invokeMutationCallbacks(); + flush(); + + expect(announcer.announce).toHaveBeenCalledWith('New content', 'polite'); + + announcerSpy.calls.reset(); + fixture.componentInstance.politeness = 'off'; + fixture.componentInstance.content = 'Newer content'; + fixture.detectChanges(); + invokeMutationCallbacks(); + flush(); + + expect(announcer.announce).not.toHaveBeenCalled(); + + announcerSpy.calls.reset(); + fixture.componentInstance.politeness = 'assertive'; + fixture.componentInstance.content = 'Newest content'; + fixture.detectChanges(); + invokeMutationCallbacks(); + flush(); + + expect(announcer.announce).toHaveBeenCalledWith('Newest content', 'assertive'); + })); +}); + function getLiveElement(): Element { return document.body.querySelector('[aria-live]')!; @@ -124,3 +195,9 @@ class TestApp { this.live.announce(message); } } + +@Component({template: `
{{content}}
`}) +class DivWithCdkAriaLive { + @Input() politeness = 'polite'; + @Input() content = 'Initial content'; +} diff --git a/src/cdk/a11y/live-announcer/live-announcer.ts b/src/cdk/a11y/live-announcer/live-announcer.ts index d423bb098f13..67748555c46d 100644 --- a/src/cdk/a11y/live-announcer/live-announcer.ts +++ b/src/cdk/a11y/live-announcer/live-announcer.ts @@ -6,15 +6,21 @@ * found in the LICENSE file at https://angular.io/license */ +import {ContentObserver} from '@angular/cdk/observers'; import {DOCUMENT} from '@angular/common'; import { + Directive, + ElementRef, Inject, Injectable, + Input, + NgZone, OnDestroy, Optional, Provider, SkipSelf, } from '@angular/core'; +import {Subscription} from 'rxjs'; import {LIVE_ANNOUNCER_ELEMENT_TOKEN} from './live-announcer-token'; @@ -81,12 +87,56 @@ export class LiveAnnouncer implements OnDestroy { } +/** + * A directive that works similarly to aria-live, but uses the LiveAnnouncer to ensure compatibility + * with a wider range of browsers and screen readers. + */ +@Directive({ + selector: '[cdkAriaLive]', + exportAs: 'cdkAriaLive', +}) +export class CdkAriaLive implements OnDestroy { + /** The aria-live politeness level to use when announcing messages. */ + @Input('cdkAriaLive') + get politeness(): AriaLivePoliteness { return this._politeness; } + set politeness(value: AriaLivePoliteness) { + this._politeness = value === 'polite' || value === 'assertive' ? value : 'off'; + if (this._politeness === 'off') { + if (this._subscription) { + this._subscription.unsubscribe(); + this._subscription = null; + } + } else { + if (!this._subscription) { + this._subscription = this._ngZone.runOutsideAngular( + () => this._contentObserver.observe(this._elementRef.nativeElement).subscribe( + () => this._liveAnnouncer.announce( + this._elementRef.nativeElement.innerText, this._politeness))); + } + } + } + private _politeness: AriaLivePoliteness = 'off'; + + private _subscription: Subscription | null; + + constructor(private _elementRef: ElementRef, private _liveAnnouncer: LiveAnnouncer, + private _contentObserver: ContentObserver, private _ngZone: NgZone) {} + + ngOnDestroy() { + if (this._subscription) { + this._subscription.unsubscribe(); + } + } +} + + /** @docs-private @deprecated @deletion-target 7.0.0 */ export function LIVE_ANNOUNCER_PROVIDER_FACTORY( parentDispatcher: LiveAnnouncer, liveElement: any, _document: any) { return parentDispatcher || new LiveAnnouncer(liveElement, _document); } + /** @docs-private @deprecated @deletion-target 7.0.0 */ export const LIVE_ANNOUNCER_PROVIDER: Provider = { // If there is already a LiveAnnouncer available, use that. Otherwise, provide a new one. diff --git a/src/material-examples/stepper-vertical/stepper-vertical-example.ts b/src/material-examples/stepper-vertical/stepper-vertical-example.ts index ba958383b4f5..13229872f1fd 100644 --- a/src/material-examples/stepper-vertical/stepper-vertical-example.ts +++ b/src/material-examples/stepper-vertical/stepper-vertical-example.ts @@ -1,4 +1,4 @@ -import {Component} from '@angular/core'; +import {Component, OnInit} from '@angular/core'; import {FormBuilder, FormGroup, Validators} from '@angular/forms'; /** @@ -9,7 +9,7 @@ import {FormBuilder, FormGroup, Validators} from '@angular/forms'; templateUrl: 'stepper-vertical-example.html', styleUrls: ['stepper-vertical-example.css'] }) -export class StepperVerticalExample { +export class StepperVerticalExample implements OnInit { isLinear = false; firstFormGroup: FormGroup; secondFormGroup: FormGroup;