Skip to content

Commit 72f9fef

Browse files
committed
feat(): add aria live announcer
Fixes #106
1 parent 82a22a7 commit 72f9fef

File tree

10 files changed

+247
-1
lines changed

10 files changed

+247
-1
lines changed

src/core/live-announcer/README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# MdLiveAnnouncer
2+
`MdLiveAnnouncer` is a service, which announces messages to several screenreaders.
3+
4+
### Methods
5+
6+
| Name | Description |
7+
| --- | --- |
8+
| `announce(message, politeness)` | This announces a text message to the supported screenreaders. <br><br>The politeness parameter sets the `aria-live` attribute on the announcer element |
9+
10+
### Examples
11+
The service can be injected in a component.
12+
```ts
13+
@Component({
14+
selector: 'my-component'
15+
providers: [MdLiveAnnouncer]
16+
})
17+
export class MyComponent {
18+
19+
constructor(live: MdLiveAnnouncer) {
20+
live.announce("Hey Google");
21+
}
22+
23+
}
24+
```
25+
26+
### Supported Screenreaders
27+
- JAWS (Windows)
28+
- NVDA (Windows)
29+
- VoiceOver (OSX and iOS)
30+
- TalkBack (Android)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@import 'mixins';
2+
3+
.md-live-announcer {
4+
@include md-visually-hidden();
5+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import {
2+
inject,
3+
TestComponentBuilder,
4+
ComponentFixture,
5+
fakeAsync,
6+
flushMicrotasks,
7+
tick, beforeEachProviders
8+
} from 'angular2/testing';
9+
import {
10+
it,
11+
describe,
12+
expect,
13+
beforeEach,
14+
} from '../../core/facade/testing';
15+
import {Component} from 'angular2/core';
16+
import {By} from 'angular2/platform/browser';
17+
import {MdLiveAnnouncer} from './live-announcer';
18+
import {DOM} from '../platform/dom/dom_adapter';
19+
20+
export function main() {
21+
describe('MdLiveAnnouncer', () => {
22+
let live: MdLiveAnnouncer;
23+
let builder: TestComponentBuilder;
24+
let announcerEl: Element;
25+
26+
beforeEachProviders(() => [MdLiveAnnouncer]);
27+
28+
beforeEach(inject([TestComponentBuilder, MdLiveAnnouncer],
29+
(tcb: TestComponentBuilder, _live: MdLiveAnnouncer) => {
30+
builder = tcb;
31+
live = _live;
32+
announcerEl = queryAnnouncerElement();
33+
}));
34+
35+
it('should correctly update the announce text', fakeAsyncTest(() => {
36+
let appFixture: ComponentFixture = null;
37+
38+
builder.createAsync(TestApp).then(fixture => {
39+
appFixture = fixture;
40+
});
41+
42+
flushMicrotasks();
43+
44+
let buttonElement = appFixture.debugElement
45+
.query(By.css('button')).nativeElement;
46+
47+
buttonElement.click();
48+
49+
// This flushes our 100ms timeout for the screenreaders.
50+
tick(100);
51+
52+
expect(announcerEl.textContent).toBe('Test');
53+
}));
54+
55+
it('should correctly update the politeness attribute', fakeAsyncTest(() => {
56+
let appFixture: ComponentFixture = null;
57+
58+
builder.createAsync(TestApp).then(fixture => {
59+
appFixture = fixture;
60+
});
61+
62+
flushMicrotasks();
63+
64+
live.announce('Hey Google', 'assertive');
65+
66+
// This flushes our 100ms timeout for the screenreaders.
67+
tick(100);
68+
69+
expect(announcerEl.textContent).toBe('Hey Google');
70+
expect(announcerEl.getAttribute('aria-live')).toBe('assertive');
71+
}));
72+
73+
it('should apply the aria-live value polite by default', fakeAsyncTest(() => {
74+
let appFixture: ComponentFixture = null;
75+
76+
builder.createAsync(TestApp).then(fixture => {
77+
appFixture = fixture;
78+
});
79+
80+
flushMicrotasks();
81+
82+
live.announce('Hey Google');
83+
84+
// This flushes our 100ms timeout for the screenreaders.
85+
tick(100);
86+
87+
expect(announcerEl.textContent).toBe('Hey Google');
88+
expect(announcerEl.getAttribute('aria-live')).toBe('polite');
89+
}));
90+
91+
});
92+
}
93+
94+
function fakeAsyncTest(fn: () => void) {
95+
return inject([], fakeAsync(fn));
96+
}
97+
98+
function queryAnnouncerElement() {
99+
let bodyEl = DOM.getGlobalEventTarget('body');
100+
return DOM.querySelector(bodyEl, '.md-live-announcer');
101+
}
102+
103+
@Component({
104+
selector: 'test-app',
105+
template: `<button (click)="announceText('Test')">Announce</button>`,
106+
providers: [MdLiveAnnouncer],
107+
})
108+
class TestApp {
109+
110+
constructor(private live: MdLiveAnnouncer) {};
111+
112+
announceText(message: string) {
113+
this.live.announce(message);
114+
}
115+
116+
}
117+
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import {Injectable} from 'angular2/core';
2+
import {DOM} from '../../core/platform/dom/dom_adapter';
3+
4+
@Injectable()
5+
export class MdLiveAnnouncer {
6+
7+
private announcerEl: Element;
8+
9+
constructor() {
10+
this.announcerEl = this._getAnnouncerElement();
11+
}
12+
13+
/**
14+
* @param message Message to be announced to the screenreader
15+
* @param politeness The politeness of the announcer element.
16+
*/
17+
announce(message: string, politeness = 'polite'): void {
18+
this.announcerEl.textContent = '';
19+
20+
this.announcerEl.setAttribute('aria-live', politeness);
21+
22+
// This 100ms timeout is necessary for some browser + screen-reader combinations:
23+
// - Both JAWS and NVDA over IE11 will not announce anything without a non-zero timeout.
24+
// - With Chrome and IE11 with NVDA or JAWS, a repeated (identical) message won't be read a
25+
// second time without clearing and then using a non-zero delay.
26+
// (using JAWS 17 at time of this writing).
27+
setTimeout(() => this.announcerEl.textContent = message, 100);
28+
}
29+
30+
private _getAnnouncerElement(): Element {
31+
let documentBody = DOM.getGlobalEventTarget('body');
32+
let childNodes = DOM.childNodes(documentBody);
33+
34+
childNodes = Array.prototype.filter
35+
.call(childNodes, (i: Node) => DOM.isElementNode(i) && DOM.hasClass(i, 'md-live-announcer'));
36+
37+
if (childNodes.length > 0) {
38+
return <Element> childNodes[0];
39+
}
40+
41+
let liveEl = DOM.createElement('div');
42+
43+
DOM.addClass(liveEl, 'md-live-announcer');
44+
DOM.setAttribute(liveEl, 'aria-atomic', 'true');
45+
DOM.setAttribute(liveEl, 'aria-live', 'polite');
46+
47+
DOM.appendChild(documentBody, liveEl);
48+
49+
return liveEl;
50+
}
51+
52+
}

src/core/style/_mixins.scss

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,19 @@
99
// Use a transform to create a new stacking context.
1010
transform: translate3D(0, 0, 0);
1111
}
12+
13+
/**
14+
* This mixin hides an element visually.
15+
* That means it's still accessible for screen-readers but not visible in view.
16+
*/
17+
@mixin md-visually-hidden {
18+
border: 0;
19+
clip: rect(0 0 0 0);
20+
height: 1px;
21+
margin: -1px;
22+
overflow: hidden;
23+
padding: 0;
24+
position: absolute;
25+
text-transform: none;
26+
width: 1px;
27+
}

src/demo-app/demo-app.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ <h1>Angular Material2 Demos</h1>
1212
<li><a [routerLink]="['ToolbarDemo']">Toolbar demo</a></li>
1313
<li><a [routerLink]="['RadioDemo']">Radio demo</a></li>
1414
<li><a [routerLink]="['ListDemo']">List demo</a></li>
15+
<li><a [routerLink]="['LiveAnnouncerDemo']">Live Announcer demo</a></li>
1516
</ul>
1617
<button md-raised-button (click)="root.dir = (root.dir == 'rtl' ? 'ltr' : 'rtl')">
1718
{{root.dir.toUpperCase()}}

src/demo-app/demo-app.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {ToolbarDemo} from './toolbar/toolbar-demo';
1313
import {OverlayDemo} from './overlay/overlay-demo';
1414
import {ListDemo} from './list/list-demo';
1515
import {InputDemo} from './input/input-demo';
16+
import {LiveAnnouncerDemo} from './live-announcer/live-announcer-demo';
1617

1718

1819
@Component({
@@ -41,6 +42,7 @@ export class Home {}
4142
new Route({path: '/checkbox', name: 'CheckboxDemo', component: CheckboxDemo}),
4243
new Route({path: '/input', name: 'InputDemo', component: InputDemo}),
4344
new Route({path: '/toolbar', name: 'ToolbarDemo', component: ToolbarDemo}),
44-
new Route({path: '/list', name: 'ListDemo', component: ListDemo})
45+
new Route({path: '/list', name: 'ListDemo', component: ListDemo}),
46+
new Route({path: '/live-announcer', name: 'LiveAnnouncerDemo', component: LiveAnnouncerDemo})
4547
])
4648
export class DemoApp { }
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<div class="demo-live-announcer">
2+
3+
<button md-button (click)="announceText('Hey Google')">Announce Text</button>
4+
5+
</div>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {Component} from 'angular2/core';
2+
import {MdLiveAnnouncer} from '../../core/live-announcer/live-announcer';
3+
4+
@Component({
5+
selector: 'toolbar-demo',
6+
templateUrl: 'demo-app/live-announcer/live-announcer-demo.html',
7+
providers: [MdLiveAnnouncer]
8+
})
9+
export class LiveAnnouncerDemo {
10+
11+
constructor(private live: MdLiveAnnouncer) {}
12+
13+
announceText(message: string) {
14+
this.live.announce(message);
15+
}
16+
17+
}

src/main.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
// that are consumed across multiple components (and thus shouldn't be scoped).
44

55
@import "core/overlay/overlay";
6+
@import "core/live-announcer/live-announcer";

0 commit comments

Comments
 (0)