Skip to content

Commit a988f34

Browse files
committed
feat(a11y): add cdkAriaLive directive
1 parent f5377dd commit a988f34

File tree

4 files changed

+133
-7
lines changed

4 files changed

+133
-7
lines changed

src/cdk/a11y/a11y-module.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,17 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {ObserversModule} from '@angular/cdk/observers';
910
import {PlatformModule} from '@angular/cdk/platform';
1011
import {CommonModule} from '@angular/common';
1112
import {NgModule} from '@angular/core';
1213
import {CdkMonitorFocus} from './focus-monitor/focus-monitor';
1314
import {CdkTrapFocus} from './focus-trap/focus-trap';
15+
import {CdkAriaLive} from './live-announcer/live-announcer';
1416

1517
@NgModule({
16-
imports: [CommonModule, PlatformModule],
17-
declarations: [CdkTrapFocus, CdkMonitorFocus],
18-
exports: [CdkTrapFocus, CdkMonitorFocus],
18+
imports: [CommonModule, PlatformModule, ObserversModule],
19+
declarations: [CdkAriaLive, CdkTrapFocus, CdkMonitorFocus],
20+
exports: [CdkAriaLive, CdkTrapFocus, CdkMonitorFocus],
1921
})
2022
export class A11yModule {}

src/cdk/a11y/live-announcer/live-announcer.spec.ts

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import {Component} from '@angular/core';
2-
import {ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing';
1+
import {MutationObserverFactory} from '@angular/cdk/observers';
2+
import {Component, Input} from '@angular/core';
3+
import {ComponentFixture, fakeAsync, flush, inject, TestBed, tick} from '@angular/core/testing';
34
import {By} from '@angular/platform-browser';
45
import {A11yModule} from '../index';
56
import {LiveAnnouncer} from './live-announcer';
67
import {LIVE_ANNOUNCER_ELEMENT_TOKEN} from './live-announcer-token';
8+
import Spy = jasmine.Spy;
79

810

911
describe('LiveAnnouncer', () => {
@@ -111,6 +113,76 @@ describe('LiveAnnouncer', () => {
111113
});
112114
});
113115

116+
describe('CdkAriaLive', () => {
117+
let mutationCallbacks: Function[] = [];
118+
let announcer: LiveAnnouncer;
119+
let announcerSpy: Spy;
120+
let fixture: ComponentFixture<DivWithCdkAriaLive>;
121+
122+
const invokeMutationCallbacks = () => mutationCallbacks.forEach(cb => cb());
123+
124+
beforeEach(fakeAsync(() => {
125+
TestBed.configureTestingModule({
126+
imports: [A11yModule],
127+
declarations: [DivWithCdkAriaLive],
128+
providers: [{
129+
provide: MutationObserverFactory,
130+
useValue: {
131+
create: function(callback: Function) {
132+
mutationCallbacks.push(callback);
133+
134+
return {
135+
observe: () => {},
136+
disconnect: () => {}
137+
};
138+
}
139+
}
140+
}]
141+
});
142+
}));
143+
144+
beforeEach(fakeAsync(inject([LiveAnnouncer], (la: LiveAnnouncer) => {
145+
announcer = la;
146+
announcerSpy = spyOn(la, 'announce').and.callThrough();
147+
fixture = TestBed.createComponent(DivWithCdkAriaLive);
148+
fixture.detectChanges();
149+
flush();
150+
})));
151+
152+
afterEach(fakeAsync(() => {
153+
// In our tests we always remove the current live element, in
154+
// order to avoid having multiple announcer elements in the DOM.
155+
announcer.ngOnDestroy();
156+
}));
157+
158+
it('should dynamically update the politeness', fakeAsync(() => {
159+
fixture.componentInstance.content = 'New content';
160+
fixture.detectChanges();
161+
invokeMutationCallbacks();
162+
flush();
163+
164+
expect(announcer.announce).toHaveBeenCalledWith('New content', 'polite');
165+
166+
announcerSpy.calls.reset();
167+
fixture.componentInstance.politeness = 'off';
168+
fixture.componentInstance.content = 'Newer content';
169+
fixture.detectChanges();
170+
invokeMutationCallbacks();
171+
flush();
172+
173+
expect(announcer.announce).not.toHaveBeenCalled();
174+
175+
announcerSpy.calls.reset();
176+
fixture.componentInstance.politeness = 'assertive';
177+
fixture.componentInstance.content = 'Newest content';
178+
fixture.detectChanges();
179+
invokeMutationCallbacks();
180+
flush();
181+
182+
expect(announcer.announce).toHaveBeenCalledWith('Newest content', 'assertive');
183+
}));
184+
});
185+
114186

115187
function getLiveElement(): Element {
116188
return document.body.querySelector('[aria-live]')!;
@@ -124,3 +196,9 @@ class TestApp {
124196
this.live.announce(message);
125197
}
126198
}
199+
200+
@Component({template: `<div [cdkAriaLive]="politeness">{{content}}</div>`})
201+
class DivWithCdkAriaLive {
202+
@Input() politeness = 'polite';
203+
@Input() content = 'Initial content';
204+
}

src/cdk/a11y/live-announcer/live-announcer.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,21 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {ContentObserver} from '@angular/cdk/observers';
910
import {DOCUMENT} from '@angular/common';
1011
import {
12+
Directive,
13+
ElementRef,
1114
Inject,
1215
Injectable,
16+
Input,
17+
NgZone,
1318
OnDestroy,
1419
Optional,
1520
Provider,
1621
SkipSelf,
1722
} from '@angular/core';
23+
import {Subscription} from 'rxjs';
1824
import {LIVE_ANNOUNCER_ELEMENT_TOKEN} from './live-announcer-token';
1925

2026

@@ -81,12 +87,52 @@ export class LiveAnnouncer implements OnDestroy {
8187
}
8288

8389

90+
/**
91+
* A directive that works similarly to aria-live, but uses the LiveAnnouncer to ensure compatibility
92+
* with a wider range of browsers and screen readers.
93+
*/
94+
@Directive({
95+
selector: '[cdkAriaLive]'
96+
})
97+
export class CdkAriaLive implements OnDestroy {
98+
@Input('cdkAriaLive')
99+
get politeness(): AriaLivePoliteness { return this._politeness; }
100+
set politeness(value: AriaLivePoliteness) {
101+
this._politeness = value === 'polite' || value === 'assertive' ? value : 'off';
102+
if (this._politeness === 'off') {
103+
if (this._subscription) {
104+
this._subscription.unsubscribe();
105+
this._subscription = null;
106+
}
107+
} else {
108+
this._subscription = this._ngZone.runOutsideAngular(
109+
() => this._contentObserver.observe(this._elementRef.nativeElement).subscribe(
110+
() => this._liveAnnouncer.announce(
111+
this._elementRef.nativeElement.innerText, this._politeness)));
112+
}
113+
}
114+
private _politeness: AriaLivePoliteness = 'off';
115+
116+
private _subscription: Subscription | null;
117+
118+
constructor(private _elementRef: ElementRef, private _liveAnnouncer: LiveAnnouncer,
119+
private _contentObserver: ContentObserver, private _ngZone: NgZone) {}
120+
121+
ngOnDestroy() {
122+
if (this._subscription) {
123+
this._subscription.unsubscribe();
124+
}
125+
}
126+
}
127+
128+
84129
/** @docs-private @deprecated @deletion-target 7.0.0 */
85130
export function LIVE_ANNOUNCER_PROVIDER_FACTORY(
86131
parentDispatcher: LiveAnnouncer, liveElement: any, _document: any) {
87132
return parentDispatcher || new LiveAnnouncer(liveElement, _document);
88133
}
89134

135+
90136
/** @docs-private @deprecated @deletion-target 7.0.0 */
91137
export const LIVE_ANNOUNCER_PROVIDER: Provider = {
92138
// If there is already a LiveAnnouncer available, use that. Otherwise, provide a new one.

src/material-examples/stepper-vertical/stepper-vertical-example.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Component} from '@angular/core';
1+
import {Component, OnInit} from '@angular/core';
22
import {FormBuilder, FormGroup, Validators} from '@angular/forms';
33

44
/**
@@ -9,7 +9,7 @@ import {FormBuilder, FormGroup, Validators} from '@angular/forms';
99
templateUrl: 'stepper-vertical-example.html',
1010
styleUrls: ['stepper-vertical-example.css']
1111
})
12-
export class StepperVerticalExample {
12+
export class StepperVerticalExample implements OnInit {
1313
isLinear = false;
1414
firstFormGroup: FormGroup;
1515
secondFormGroup: FormGroup;

0 commit comments

Comments
 (0)