Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.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 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()}),
]);