Skip to content

Commit 64a70ad

Browse files
authored
feat(a11y): add cdkAriaLive directive (#11352)
* feat(a11y): add cdkAriaLive directive * fix bazel * address comments * disable bazel tests for cdk/a11y
1 parent f1f34bc commit 64a70ad

File tree

4 files changed

+138
-5
lines changed

4 files changed

+138
-5
lines changed

src/cdk/a11y/BUILD.bazel

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ ng_module(
1111
deps = [
1212
"//src/cdk/coercion",
1313
"//src/cdk/keycodes",
14+
"//src/cdk/observers",
1415
"//src/cdk/platform",
1516
"@rxjs",
1617
],
@@ -35,6 +36,7 @@ ts_library(
3536
deps = [
3637
":a11y",
3738
"//src/cdk/keycodes",
39+
"//src/cdk/observers",
3840
"//src/cdk/platform",
3941
"//src/cdk/testing",
4042
"@rxjs",
@@ -45,6 +47,8 @@ ts_library(
4547

4648
ts_web_test(
4749
name = "unit_tests",
50+
# TODO(mmalerba): re-enable once ngfactory issue is resolved.
51+
tags = ["manual"],
4852
bootstrap = [
4953
"//:web_test_bootstrap_scripts",
5054
],

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: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
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';
@@ -111,6 +112,76 @@ describe('LiveAnnouncer', () => {
111112
});
112113
});
113114

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

115186
function getLiveElement(): Element {
116187
return document.body.querySelector('[aria-live]')!;
@@ -124,3 +195,9 @@ class TestApp {
124195
this.live.announce(message);
125196
}
126197
}
198+
199+
@Component({template: `<div [cdkAriaLive]="politeness">{{content}}</div>`})
200+
class DivWithCdkAriaLive {
201+
@Input() politeness = 'polite';
202+
@Input() content = 'Initial content';
203+
}

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

Lines changed: 50 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,56 @@ 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+
exportAs: 'cdkAriaLive',
97+
})
98+
export class CdkAriaLive implements OnDestroy {
99+
/** The aria-live politeness level to use when announcing messages. */
100+
@Input('cdkAriaLive')
101+
get politeness(): AriaLivePoliteness { return this._politeness; }
102+
set politeness(value: AriaLivePoliteness) {
103+
this._politeness = value === 'polite' || value === 'assertive' ? value : 'off';
104+
if (this._politeness === 'off') {
105+
if (this._subscription) {
106+
this._subscription.unsubscribe();
107+
this._subscription = null;
108+
}
109+
} else {
110+
if (!this._subscription) {
111+
this._subscription = this._ngZone.runOutsideAngular(
112+
() => this._contentObserver.observe(this._elementRef.nativeElement).subscribe(
113+
() => this._liveAnnouncer.announce(
114+
this._elementRef.nativeElement.innerText, this._politeness)));
115+
}
116+
}
117+
}
118+
private _politeness: AriaLivePoliteness = 'off';
119+
120+
private _subscription: Subscription | null;
121+
122+
constructor(private _elementRef: ElementRef, private _liveAnnouncer: LiveAnnouncer,
123+
private _contentObserver: ContentObserver, private _ngZone: NgZone) {}
124+
125+
ngOnDestroy() {
126+
if (this._subscription) {
127+
this._subscription.unsubscribe();
128+
}
129+
}
130+
}
131+
132+
84133
/** @docs-private @deprecated @deletion-target 7.0.0 */
85134
export function LIVE_ANNOUNCER_PROVIDER_FACTORY(
86135
parentDispatcher: LiveAnnouncer, liveElement: any, _document: any) {
87136
return parentDispatcher || new LiveAnnouncer(liveElement, _document);
88137
}
89138

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

0 commit comments

Comments
 (0)