Skip to content

feat(a11y): add cdkAriaLive directive #11352

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 22, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/cdk/a11y/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ ng_module(
deps = [
"//src/cdk/coercion",
"//src/cdk/keycodes",
"//src/cdk/observers",
"//src/cdk/platform",
"@rxjs",
],
Expand All @@ -35,6 +36,7 @@ ts_library(
deps = [
":a11y",
"//src/cdk/keycodes",
"//src/cdk/observers",
"//src/cdk/platform",
"//src/cdk/testing",
"@rxjs",
Expand All @@ -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",
],
Expand Down
8 changes: 5 additions & 3 deletions src/cdk/a11y/a11y-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
81 changes: 79 additions & 2 deletions src/cdk/a11y/live-announcer/live-announcer.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -111,6 +112,76 @@ describe('LiveAnnouncer', () => {
});
});

describe('CdkAriaLive', () => {
let mutationCallbacks: Function[] = [];
let announcer: LiveAnnouncer;
let announcerSpy: jasmine.Spy;
let fixture: ComponentFixture<DivWithCdkAriaLive>;

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]')!;
Expand All @@ -124,3 +195,9 @@ class TestApp {
this.live.announce(message);
}
}

@Component({template: `<div [cdkAriaLive]="politeness">{{content}}</div>`})
class DivWithCdkAriaLive {
@Input() politeness = 'polite';
@Input() content = 'Initial content';
}
50 changes: 50 additions & 0 deletions src/cdk/a11y/live-announcer/live-announcer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';


Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Component} from '@angular/core';
import {Component, OnInit} from '@angular/core';
import {FormBuilder, FormGroup, Validators} from '@angular/forms';

/**
Expand All @@ -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;
Expand Down