Skip to content

Commit

Permalink
feat(): add aria live announcer
Browse files Browse the repository at this point in the history
Fixes #106
  • Loading branch information
devversion committed Apr 6, 2016
1 parent 82a22a7 commit 7b52d5a
Show file tree
Hide file tree
Showing 10 changed files with 250 additions and 1 deletion.
37 changes: 37 additions & 0 deletions src/core/live-announcer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# MdLiveAnnouncer
`MdLiveAnnouncer` is a component, which announces messages to several screenreaders.

## `<md-live-announcer>`
### Methods

| Name | Description |
| --- | --- |
| `announce(message: string)` | This announces a text message to the screenreader |

### Examples
A basic local variable can be assigned to the `md-live-announcer` component.
```html
<md-live-announcer #live>
<button (click)="live.announce('Hey Google')">Announce Text</button>
</md-live-announcer>
```

The component is also useable through the Dependency Injection.

```ts
export class ChildComponent {

constructor(private live: MdLiveAnnouncer) { }

announceText() {
this.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();
}
117 changes: 117 additions & 0 deletions src/core/live-announcer/live-announcer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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';
import {DOM} from '../platform/dom/dom_adapter';

export function main() {
describe('MdLiveAnnouncer', () => {
let live: MdLiveAnnouncer;
let builder: TestComponentBuilder;
let announcerEl: Element;

beforeEachProviders(() => [MdLiveAnnouncer]);

beforeEach(inject([TestComponentBuilder, MdLiveAnnouncer],
(tcb: TestComponentBuilder, _live: MdLiveAnnouncer) => {
builder = tcb;
live = _live;
announcerEl = queryAnnouncerElement();
}));

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(announcerEl.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(announcerEl.textContent).toBe('Hey Google');
expect(announcerEl.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(announcerEl.textContent).toBe('Hey Google');
expect(announcerEl.getAttribute('aria-live')).toBe('polite');
}));

});
}

function fakeAsyncTest(fn: () => void) {
return inject([], fakeAsync(fn));
}

function queryAnnouncerElement() {
let bodyEl = DOM.getGlobalEventTarget('body');
return DOM.querySelector(bodyEl, '.md-live-announcer');
}

@Component({
selector: 'test-app',
template: `<button (click)="announceText('Test')">Announce</button>`,
providers: [MdLiveAnnouncer],
})
class TestApp {

constructor(private live: MdLiveAnnouncer) {};

announceText(message: string) {
this.live.announce(message);
}

}

48 changes: 48 additions & 0 deletions src/core/live-announcer/live-announcer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {Injectable} from 'angular2/core';
import {DOM} from '../../core/platform/dom/dom_adapter';

@Injectable()
export class MdLiveAnnouncer {

private announcerEl: Element;

constructor() {
this.announcerEl = this._getAnnouncerElement();
}

announce(message: string, politeness = 'polite'): void {
this.announcerEl.textContent = '';

this.announcerEl.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.announcerEl.textContent = message, 100);
}

private _getAnnouncerElement(): Element {
let documentBody = DOM.getGlobalEventTarget('body');
let childNodes = DOM.childNodes(documentBody);

childNodes = Array.prototype.filter
.call(childNodes, (i: Node) => DOM.isElementNode(i) && DOM.hasClass(i, 'md-live-announcer'));

if (childNodes.length > 0) {
return <Element> childNodes[0];
}

let liveEl = DOM.createElement('div');

DOM.addClass(liveEl, 'md-live-announcer');
DOM.setAttribute(liveEl, 'aria-atomic', 'true');
DOM.setAttribute(liveEl, 'aria-live', 'polite');

DOM.appendChild(documentBody, 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>
17 changes: 17 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,17 @@
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',
providers: [MdLiveAnnouncer]
})
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";

0 comments on commit 7b52d5a

Please sign in to comment.