Skip to content

Commit

Permalink
feat(): add aria live announcer
Browse files Browse the repository at this point in the history
  • Loading branch information
devversion committed Apr 6, 2016
1 parent 82a22a7 commit 3883045
Show file tree
Hide file tree
Showing 11 changed files with 244 additions and 1 deletion.
30 changes: 30 additions & 0 deletions src/core/live-announcer/README.md
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions src/core/live-announcer/live-announcer.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@import 'mixins';

.md-live-announcer {
@include md-visually-hidden();
}
121 changes: 121 additions & 0 deletions src/core/live-announcer/live-announcer.spec.ts
Original file line number Diff line number Diff line change
@@ -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.remove();
});

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 changes: 44 additions & 0 deletions src/core/live-announcer/live-announcer.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 16 additions & 0 deletions src/core/style/_mixins.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 change: 1 addition & 0 deletions src/demo-app/demo-app.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ <h1>Angular Material2 Demos</h1>
<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()}}
Expand Down
4 changes: 3 additions & 1 deletion src/demo-app/demo-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 { }
5 changes: 5 additions & 0 deletions src/demo-app/live-announcer/live-announcer-demo.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div class="demo-live-announcer">

<button md-button (click)="announceText('Hey Google')">Announce Text</button>

</div>
16 changes: 16 additions & 0 deletions src/demo-app/live-announcer/live-announcer-demo.ts
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions src/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ 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';

BrowserDomAdapter.makeCurrent();

bootstrap(DemoApp, [
ROUTER_PROVIDERS,
MdLiveAnnouncer,
provide(OVERLAY_CONTAINER_TOKEN, {useValue: createOverlayContainer()}),
]);

0 comments on commit 3883045

Please sign in to comment.