feat(): add aria live announcer

Fixes #106
This commit is contained in:
Paul Gschwendtner
2016-04-06 20:24:23 +02:00
committed by Jeremy Elbourn
parent 82a22a71d0
commit e99da663f7
11 changed files with 244 additions and 1 deletions
+30
View File
@@ -0,0 +1,30 @@
# MdLiveAnnouncer
`MdLiveAnnouncer` is a service, which announces messages to several screenreaders.
### Methods
| Name | Description |
| --- | --- |
| `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 |
### Examples
The service can be injected in a component.
```ts
@Component({
selector: 'my-component'
providers: [MdLiveAnnouncer]
})
export class MyComponent {
constructor(live: MdLiveAnnouncer) {
live.announce("Hey Google");
}
}
```
### Supported Screenreaders
- JAWS (Windows)
- NVDA (Windows)
- VoiceOver (OSX and iOS)
- TalkBack (Android)
@@ -0,0 +1,5 @@
@import 'mixins';
.md-live-announcer {
@include md-visually-hidden();
}
@@ -0,0 +1,121 @@
import {
inject,
TestComponentBuilder,
ComponentFixture,
fakeAsync,
flushMicrotasks,
tick,
beforeEachProviders
} from 'angular2/testing';
import {
it,
describe,
expect,
beforeEach,
} from '../../core/facade/testing';
import {Component} from 'angular2/core';
import {By} from 'angular2/platform/browser';
import {MdLiveAnnouncer} from './live-announcer';
export function main() {
describe('MdLiveAnnouncer', () => {
let live: MdLiveAnnouncer;
let builder: TestComponentBuilder;
let liveEl: Element;
beforeEachProviders(() => [MdLiveAnnouncer]);
beforeEach(inject([TestComponentBuilder, MdLiveAnnouncer],
(tcb: TestComponentBuilder, _live: MdLiveAnnouncer) => {
builder = tcb;
live = _live;
liveEl = getLiveElement();
}));
afterEach(() => {
// In our tests we always remove the current live element, because otherwise we would have
// multiple live elements due multiple service instantiations.
liveEl.parentNode.removeChild(liveEl);
});
it('should correctly update the announce text', fakeAsyncTest(() => {
let appFixture: ComponentFixture = null;
builder.createAsync(TestApp).then(fixture => {
appFixture = fixture;
});
flushMicrotasks();
let buttonElement = appFixture.debugElement
.query(By.css('button')).nativeElement;
buttonElement.click();
// This flushes our 100ms timeout for the screenreaders.
tick(100);
expect(liveEl.textContent).toBe('Test');
}));
it('should correctly update the politeness attribute', fakeAsyncTest(() => {
let appFixture: ComponentFixture = null;
builder.createAsync(TestApp).then(fixture => {
appFixture = fixture;
});
flushMicrotasks();
live.announce('Hey Google', 'assertive');
// This flushes our 100ms timeout for the screenreaders.
tick(100);
expect(liveEl.textContent).toBe('Hey Google');
expect(liveEl.getAttribute('aria-live')).toBe('assertive');
}));
it('should apply the aria-live value polite by default', fakeAsyncTest(() => {
let appFixture: ComponentFixture = null;
builder.createAsync(TestApp).then(fixture => {
appFixture = fixture;
});
flushMicrotasks();
live.announce('Hey Google');
// This flushes our 100ms timeout for the screenreaders.
tick(100);
expect(liveEl.textContent).toBe('Hey Google');
expect(liveEl.getAttribute('aria-live')).toBe('polite');
}));
});
}
function fakeAsyncTest(fn: () => void) {
return inject([], fakeAsync(fn));
}
function getLiveElement(): Element {
return document.body.querySelector('.md-live-announcer');
}
@Component({
selector: 'test-app',
template: `<button (click)="announceText('Test')">Announce</button>`,
})
class TestApp {
constructor(private live: MdLiveAnnouncer) {};
announceText(message: string) {
this.live.announce(message);
}
}
+44
View File
@@ -0,0 +1,44 @@
import {Injectable} from 'angular2/core';
export type AriaLivePoliteness = 'off' | 'polite' | 'assertive';
@Injectable()
export class MdLiveAnnouncer {
private _liveElement: Element;
constructor() {
this._liveElement = this._createLiveElement();
}
/**
* @param message Message to be announced to the screenreader
* @param politeness The politeness of the announcer element.
*/
announce(message: string, politeness: AriaLivePoliteness = 'polite'): void {
this._liveElement.textContent = '';
// TODO: ensure changing the politeness works on all environments we support.
this._liveElement.setAttribute('aria-live', politeness);
// This 100ms timeout is necessary for some browser + screen-reader combinations:
// - Both JAWS and NVDA over IE11 will not announce anything without a non-zero timeout.
// - With Chrome and IE11 with NVDA or JAWS, a repeated (identical) message won't be read a
// second time without clearing and then using a non-zero delay.
// (using JAWS 17 at time of this writing).
setTimeout(() => this._liveElement.textContent = message, 100);
}
private _createLiveElement(): Element {
let liveEl = document.createElement('div');
liveEl.classList.add('md-live-announcer');
liveEl.setAttribute('aria-atomic', 'true');
liveEl.setAttribute('aria-live', 'polite');
document.body.appendChild(liveEl);
return liveEl;
}
}
+16
View File
@@ -9,3 +9,19 @@
// Use a transform to create a new stacking context.
transform: translate3D(0, 0, 0);
}
/**
* This mixin hides an element visually.
* That means it's still accessible for screen-readers but not visible in view.
*/
@mixin md-visually-hidden {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
text-transform: none;
width: 1px;
}
+1
View File
@@ -12,6 +12,7 @@
<li><a [routerLink]="['ToolbarDemo']">Toolbar demo</a></li>
<li><a [routerLink]="['RadioDemo']">Radio demo</a></li>
<li><a [routerLink]="['ListDemo']">List demo</a></li>
<li><a [routerLink]="['LiveAnnouncerDemo']">Live Announcer demo</a></li>
</ul>
<button md-raised-button (click)="root.dir = (root.dir == 'rtl' ? 'ltr' : 'rtl')">
{{root.dir.toUpperCase()}}
+3 -1
View File
@@ -13,6 +13,7 @@ import {ToolbarDemo} from './toolbar/toolbar-demo';
import {OverlayDemo} from './overlay/overlay-demo';
import {ListDemo} from './list/list-demo';
import {InputDemo} from './input/input-demo';
import {LiveAnnouncerDemo} from './live-announcer/live-announcer-demo';
@Component({
@@ -41,6 +42,7 @@ export class Home {}
new Route({path: '/checkbox', name: 'CheckboxDemo', component: CheckboxDemo}),
new Route({path: '/input', name: 'InputDemo', component: InputDemo}),
new Route({path: '/toolbar', name: 'ToolbarDemo', component: ToolbarDemo}),
new Route({path: '/list', name: 'ListDemo', component: ListDemo})
new Route({path: '/list', name: 'ListDemo', component: ListDemo}),
new Route({path: '/live-announcer', name: 'LiveAnnouncerDemo', component: LiveAnnouncerDemo})
])
export class DemoApp { }
@@ -0,0 +1,5 @@
<div class="demo-live-announcer">
<button md-button (click)="announceText('Hey Google')">Announce Text</button>
</div>
@@ -0,0 +1,16 @@
import {Component} from 'angular2/core';
import {MdLiveAnnouncer} from '../../core/live-announcer/live-announcer';
@Component({
selector: 'toolbar-demo',
templateUrl: 'demo-app/live-announcer/live-announcer-demo.html',
})
export class LiveAnnouncerDemo {
constructor(private live: MdLiveAnnouncer) {}
announceText(message: string) {
this.live.announce(message);
}
}
+1
View File
@@ -3,3 +3,4 @@
// that are consumed across multiple components (and thus shouldn't be scoped).
@import "core/overlay/overlay";
@import "core/live-announcer/live-announcer";
+2
View File
@@ -3,6 +3,7 @@ import {DemoApp} from './demo-app/demo-app';
import {ROUTER_PROVIDERS} from 'angular2/router';
import {BrowserDomAdapter} from './core/platform/browser/browser_adapter';
import {OVERLAY_CONTAINER_TOKEN} from './core/overlay/overlay';
import {MdLiveAnnouncer} from './core/live-announcer/live-announcer';
import {provide} from 'angular2/core';
import {createOverlayContainer} from './core/overlay/overlay-container';
@@ -10,5 +11,6 @@ BrowserDomAdapter.makeCurrent();
bootstrap(DemoApp, [
ROUTER_PROVIDERS,
MdLiveAnnouncer,
provide(OVERLAY_CONTAINER_TOKEN, {useValue: createOverlayContainer()}),
]);