Skip to content

Commit d1acbaf

Browse files
committed
feat(): add aria live announcer
Fixes angular#106
1 parent 5a6657a commit d1acbaf

File tree

10 files changed

+237
-1
lines changed

10 files changed

+237
-1
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# MdLiveAnnouncer
2+
`MdLiveAnnouncer` is a component, which announces messages to several screenreaders.
3+
4+
## `<md-live-announcer>`
5+
### Methods
6+
7+
| Name | Description |
8+
| --- | --- |
9+
| `announce(message: string)` | This announces a text message to the screenreader |
10+
11+
### Examples
12+
A basic local variable can be assigned to the `md-live-announcer` component.
13+
```html
14+
<md-live-announcer #live>
15+
<button (click)="live.announce('Hey Google')">Announce Text</button>
16+
</md-live-announcer>
17+
```
18+
19+
The component is also useable through the Dependency Injection.
20+
21+
```ts
22+
export class ChildComponent {
23+
24+
constructor(private live: MdLiveAnnouncer) { }
25+
26+
announceText() {
27+
this.live.announce("Hey Google");
28+
}
29+
30+
}
31+
```
32+
33+
### Supported Screenreaders
34+
- JAWS (Windows)
35+
- NVDA (Windows)
36+
- VoiceOver (OSX and iOS)
37+
- TalkBack (Android)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<ng-content></ng-content>
2+
<div class="md-live-announcer" aria-live="polite" aria-atomic="true"></div>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
@import 'mixins';
2+
3+
:host {
4+
5+
.md-live-announcer {
6+
@include md-visually-hidden();
7+
}
8+
9+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import {
2+
inject,
3+
TestComponentBuilder,
4+
ComponentFixture,
5+
fakeAsync,
6+
flushMicrotasks,
7+
tick
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+
19+
export function main() {
20+
describe('MdLiveAnnouncer', () => {
21+
let builder: TestComponentBuilder;
22+
23+
beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => {
24+
builder = tcb;
25+
}));
26+
27+
it('should correctly update the announce text', fakeAsyncTest(() => {
28+
let appFixture: ComponentFixture = null;
29+
30+
builder.createAsync(TestApp).then(fixture => {
31+
appFixture = fixture;
32+
});
33+
34+
flushMicrotasks();
35+
36+
let announcerElement = appFixture.debugElement
37+
.query(By.css('.md-live-announcer')).nativeElement;
38+
let buttonElement = appFixture.debugElement
39+
.query(By.css('button')).nativeElement;
40+
41+
appFixture.detectChanges();
42+
43+
buttonElement.click();
44+
45+
// This flushes our 100ms timeout for the screenreaders.
46+
tick(100);
47+
48+
expect(announcerElement.textContent).toBe('Test');
49+
}));
50+
51+
it('should correctly update the politeness attribute', fakeAsyncTest(() => {
52+
let appFixture: ComponentFixture = null;
53+
54+
builder.createAsync(TestApp).then(fixture => {
55+
appFixture = fixture;
56+
});
57+
58+
flushMicrotasks();
59+
60+
let live: MdLiveAnnouncer = appFixture.debugElement
61+
.query(By.css('md-live-announcer')).componentInstance;
62+
let announcerElement = appFixture.debugElement
63+
.query(By.css('.md-live-announcer')).nativeElement;
64+
65+
appFixture.detectChanges();
66+
67+
live.announce('Hey Google', 'assertive');
68+
69+
// This flushes our 100ms timeout for the screenreaders.
70+
tick(100);
71+
72+
expect(announcerElement.textContent).toBe('Hey Google');
73+
expect(announcerElement.getAttribute('aria-live')).toBe('assertive');
74+
}));
75+
76+
it('should apply the aria-live value polite by default', fakeAsyncTest(() => {
77+
let appFixture: ComponentFixture = null;
78+
79+
builder.createAsync(TestApp).then(fixture => {
80+
appFixture = fixture;
81+
});
82+
83+
flushMicrotasks();
84+
85+
let live: MdLiveAnnouncer = appFixture.debugElement
86+
.query(By.css('md-live-announcer')).componentInstance;
87+
let announcerElement = appFixture.debugElement
88+
.query(By.css('.md-live-announcer')).nativeElement;
89+
90+
appFixture.detectChanges();
91+
92+
live.announce('Hey Google');
93+
94+
// This flushes our 100ms timeout for the screenreaders.
95+
tick(100);
96+
97+
expect(announcerElement.textContent).toBe('Hey Google');
98+
expect(announcerElement.getAttribute('aria-live')).toBe('polite');
99+
}));
100+
101+
});
102+
}
103+
104+
function fakeAsyncTest(fn: () => void) {
105+
return inject([], fakeAsync(fn));
106+
}
107+
108+
@Component({
109+
selector: 'test-app',
110+
template: `
111+
<md-live-announcer #announcer>
112+
<button (click)="announcer.announce('Test')">Announce</button>
113+
</md-live-announcer>
114+
`,
115+
directives: [MdLiveAnnouncer],
116+
})
117+
class TestApp {
118+
}
119+
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {
2+
Component,
3+
ElementRef,
4+
AfterContentInit
5+
} from 'angular2/core';
6+
7+
import {DOM} from '../../core/platform/dom/dom_adapter';
8+
9+
@Component({
10+
selector: 'md-live-announcer',
11+
templateUrl: './components/live-announcer/live-announcer.html',
12+
styleUrls: ['./components/live-announcer/live-announcer.css'],
13+
})
14+
export class MdLiveAnnouncer implements AfterContentInit {
15+
16+
private announcerEl: HTMLElement;
17+
18+
constructor(private elementRef: ElementRef) { }
19+
20+
ngAfterContentInit() {
21+
this.announcerEl = DOM.querySelector(this.elementRef.nativeElement, '.md-live-announcer');
22+
}
23+
24+
announce(message: string, politeness = 'polite'): void {
25+
this.announcerEl.textContent = '';
26+
27+
this.announcerEl.setAttribute('aria-live', politeness);
28+
29+
// This 100ms timeout is necessary for some browser + screen-reader combinations:
30+
// - Both JAWS and NVDA over IE11 will not announce anything without a non-zero timeout.
31+
// - With Chrome and IE11 with NVDA or JAWS, a repeated (identical) message won't be read a
32+
// second time without clearing and then using a non-zero delay.
33+
// (using JAWS 17 at time of this writing).
34+
setTimeout(() => this.announcerEl.textContent = message, 100);
35+
}
36+
}

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+
<md-live-announcer #live>
2+
3+
<button md-button (click)="live.announce('Hey Google')">Announce Text</button>
4+
5+
</md-live-announcer>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import {Component} from 'angular2/core';
2+
import {MdLiveAnnouncer} from '../../components/live-announcer/live-announcer';
3+
4+
@Component({
5+
selector: 'toolbar-demo',
6+
templateUrl: 'demo-app/live-announcer/live-announcer-demo.html',
7+
directives: [MdLiveAnnouncer]
8+
})
9+
export class LiveAnnouncerDemo {}

0 commit comments

Comments
 (0)