Skip to content

Commit e99da66

Browse files
devversionjelbourn
authored andcommitted
feat(): add aria live announcer
Fixes #106
1 parent 82a22a7 commit e99da66

11 files changed

+244
-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: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import {
2+
inject,
3+
TestComponentBuilder,
4+
ComponentFixture,
5+
fakeAsync,
6+
flushMicrotasks,
7+
tick,
8+
beforeEachProviders
9+
} from 'angular2/testing';
10+
import {
11+
it,
12+
describe,
13+
expect,
14+
beforeEach,
15+
} from '../../core/facade/testing';
16+
import {Component} from 'angular2/core';
17+
import {By} from 'angular2/platform/browser';
18+
import {MdLiveAnnouncer} from './live-announcer';
19+
20+
export function main() {
21+
describe('MdLiveAnnouncer', () => {
22+
let live: MdLiveAnnouncer;
23+
let builder: TestComponentBuilder;
24+
let liveEl: Element;
25+
26+
beforeEachProviders(() => [MdLiveAnnouncer]);
27+
28+
beforeEach(inject([TestComponentBuilder, MdLiveAnnouncer],
29+
(tcb: TestComponentBuilder, _live: MdLiveAnnouncer) => {
30+
builder = tcb;
31+
live = _live;
32+
liveEl = getLiveElement();
33+
}));
34+
35+
afterEach(() => {
36+
// In our tests we always remove the current live element, because otherwise we would have
37+
// multiple live elements due multiple service instantiations.
38+
liveEl.parentNode.removeChild(liveEl);
39+
});
40+
41+
it('should correctly update the announce text', fakeAsyncTest(() => {
42+
let appFixture: ComponentFixture = null;
43+
44+
builder.createAsync(TestApp).then(fixture => {
45+
appFixture = fixture;
46+
});
47+
48+
flushMicrotasks();
49+
50+
let buttonElement = appFixture.debugElement
51+
.query(By.css('button')).nativeElement;
52+
53+
buttonElement.click();
54+
55+
// This flushes our 100ms timeout for the screenreaders.
56+
tick(100);
57+
58+
expect(liveEl.textContent).toBe('Test');
59+
}));
60+
61+
it('should correctly update the politeness attribute', fakeAsyncTest(() => {
62+
let appFixture: ComponentFixture = null;
63+
64+
builder.createAsync(TestApp).then(fixture => {
65+
appFixture = fixture;
66+
});
67+
68+
flushMicrotasks();
69+
70+
live.announce('Hey Google', 'assertive');
71+
72+
// This flushes our 100ms timeout for the screenreaders.
73+
tick(100);
74+
75+
expect(liveEl.textContent).toBe('Hey Google');
76+
expect(liveEl.getAttribute('aria-live')).toBe('assertive');
77+
}));
78+
79+
it('should apply the aria-live value polite by default', fakeAsyncTest(() => {
80+
let appFixture: ComponentFixture = null;
81+
82+
builder.createAsync(TestApp).then(fixture => {
83+
appFixture = fixture;
84+
});
85+
86+
flushMicrotasks();
87+
88+
live.announce('Hey Google');
89+
90+
// This flushes our 100ms timeout for the screenreaders.
91+
tick(100);
92+
93+
expect(liveEl.textContent).toBe('Hey Google');
94+
expect(liveEl.getAttribute('aria-live')).toBe('polite');
95+
}));
96+
97+
});
98+
}
99+
100+
function fakeAsyncTest(fn: () => void) {
101+
return inject([], fakeAsync(fn));
102+
}
103+
104+
function getLiveElement(): Element {
105+
return document.body.querySelector('.md-live-announcer');
106+
}
107+
108+
@Component({
109+
selector: 'test-app',
110+
template: `<button (click)="announceText('Test')">Announce</button>`,
111+
})
112+
class TestApp {
113+
114+
constructor(private live: MdLiveAnnouncer) {};
115+
116+
announceText(message: string) {
117+
this.live.announce(message);
118+
}
119+
120+
}
121+
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import {Injectable} from 'angular2/core';
2+
3+
export type AriaLivePoliteness = 'off' | 'polite' | 'assertive';
4+
5+
@Injectable()
6+
export class MdLiveAnnouncer {
7+
8+
private _liveElement: Element;
9+
10+
constructor() {
11+
this._liveElement = this._createLiveElement();
12+
}
13+
14+
/**
15+
* @param message Message to be announced to the screenreader
16+
* @param politeness The politeness of the announcer element.
17+
*/
18+
announce(message: string, politeness: AriaLivePoliteness = 'polite'): void {
19+
this._liveElement.textContent = '';
20+
21+
// TODO: ensure changing the politeness works on all environments we support.
22+
this._liveElement.setAttribute('aria-live', politeness);
23+
24+
// This 100ms timeout is necessary for some browser + screen-reader combinations:
25+
// - Both JAWS and NVDA over IE11 will not announce anything without a non-zero timeout.
26+
// - With Chrome and IE11 with NVDA or JAWS, a repeated (identical) message won't be read a
27+
// second time without clearing and then using a non-zero delay.
28+
// (using JAWS 17 at time of this writing).
29+
setTimeout(() => this._liveElement.textContent = message, 100);
30+
}
31+
32+
private _createLiveElement(): Element {
33+
let liveEl = document.createElement('div');
34+
35+
liveEl.classList.add('md-live-announcer');
36+
liveEl.setAttribute('aria-atomic', 'true');
37+
liveEl.setAttribute('aria-live', 'polite');
38+
39+
document.body.appendChild(liveEl);
40+
41+
return liveEl;
42+
}
43+
44+
}

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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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+
})
8+
export class LiveAnnouncerDemo {
9+
10+
constructor(private live: MdLiveAnnouncer) {}
11+
12+
announceText(message: string) {
13+
this.live.announce(message);
14+
}
15+
16+
}

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";

src/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import {DemoApp} from './demo-app/demo-app';
33
import {ROUTER_PROVIDERS} from 'angular2/router';
44
import {BrowserDomAdapter} from './core/platform/browser/browser_adapter';
55
import {OVERLAY_CONTAINER_TOKEN} from './core/overlay/overlay';
6+
import {MdLiveAnnouncer} from './core/live-announcer/live-announcer';
67
import {provide} from 'angular2/core';
78
import {createOverlayContainer} from './core/overlay/overlay-container';
89

910
BrowserDomAdapter.makeCurrent();
1011

1112
bootstrap(DemoApp, [
1213
ROUTER_PROVIDERS,
14+
MdLiveAnnouncer,
1315
provide(OVERLAY_CONTAINER_TOKEN, {useValue: createOverlayContainer()}),
1416
]);

0 commit comments

Comments
 (0)