diff --git a/src/core/live-announcer/README.md b/src/core/live-announcer/README.md new file mode 100644 index 000000000000..9c2e99a6e441 --- /dev/null +++ b/src/core/live-announcer/README.md @@ -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.

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) diff --git a/src/core/live-announcer/live-announcer.scss b/src/core/live-announcer/live-announcer.scss new file mode 100644 index 000000000000..c4eeae7d2682 --- /dev/null +++ b/src/core/live-announcer/live-announcer.scss @@ -0,0 +1,5 @@ +@import 'mixins'; + +.md-live-announcer { + @include md-visually-hidden(); +} \ No newline at end of file diff --git a/src/core/live-announcer/live-announcer.spec.ts b/src/core/live-announcer/live-announcer.spec.ts new file mode 100644 index 000000000000..242a3ef4edc4 --- /dev/null +++ b/src/core/live-announcer/live-announcer.spec.ts @@ -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: ``, +}) +class TestApp { + + constructor(private live: MdLiveAnnouncer) {}; + + announceText(message: string) { + this.live.announce(message); + } + +} + diff --git a/src/core/live-announcer/live-announcer.ts b/src/core/live-announcer/live-announcer.ts new file mode 100644 index 000000000000..560145ca69c8 --- /dev/null +++ b/src/core/live-announcer/live-announcer.ts @@ -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; + } + +} diff --git a/src/core/style/_mixins.scss b/src/core/style/_mixins.scss index e809739c037f..b9445b392c7a 100644 --- a/src/core/style/_mixins.scss +++ b/src/core/style/_mixins.scss @@ -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; +} \ No newline at end of file diff --git a/src/demo-app/demo-app.html b/src/demo-app/demo-app.html index 9ce3d2de0ea1..1b2a4e037789 100644 --- a/src/demo-app/demo-app.html +++ b/src/demo-app/demo-app.html @@ -12,6 +12,7 @@

Angular Material2 Demos

  • Toolbar demo
  • Radio demo
  • List demo
  • +
  • Live Announcer demo
  • + + \ No newline at end of file diff --git a/src/demo-app/live-announcer/live-announcer-demo.ts b/src/demo-app/live-announcer/live-announcer-demo.ts new file mode 100644 index 000000000000..e5b381749a9e --- /dev/null +++ b/src/demo-app/live-announcer/live-announcer-demo.ts @@ -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); + } + +} diff --git a/src/main.scss b/src/main.scss index 5dddf74fd1da..7b92647788dd 100644 --- a/src/main.scss +++ b/src/main.scss @@ -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"; \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 42f2c96d974d..916d6523a920 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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()}), ]);