From 7b52d5a6c45b108624ac917e3e3769acf13d0d7b Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Wed, 6 Apr 2016 16:07:37 +0200 Subject: [PATCH] feat(): add aria live announcer Fixes #106 --- src/core/live-announcer/README.md | 37 ++++++ src/core/live-announcer/live-announcer.scss | 5 + .../live-announcer/live-announcer.spec.ts | 117 ++++++++++++++++++ src/core/live-announcer/live-announcer.ts | 48 +++++++ src/core/style/_mixins.scss | 16 +++ src/demo-app/demo-app.html | 1 + src/demo-app/demo-app.ts | 4 +- .../live-announcer/live-announcer-demo.html | 5 + .../live-announcer/live-announcer-demo.ts | 17 +++ src/main.scss | 1 + 10 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 src/core/live-announcer/README.md create mode 100644 src/core/live-announcer/live-announcer.scss create mode 100644 src/core/live-announcer/live-announcer.spec.ts create mode 100644 src/core/live-announcer/live-announcer.ts create mode 100644 src/demo-app/live-announcer/live-announcer-demo.html create mode 100644 src/demo-app/live-announcer/live-announcer-demo.ts diff --git a/src/core/live-announcer/README.md b/src/core/live-announcer/README.md new file mode 100644 index 000000000000..7473aa561ca6 --- /dev/null +++ b/src/core/live-announcer/README.md @@ -0,0 +1,37 @@ +# MdLiveAnnouncer +`MdLiveAnnouncer` is a component, which announces messages to several screenreaders. + +## `` +### 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 + + + +``` + +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) 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..a820a81788d5 --- /dev/null +++ b/src/core/live-announcer/live-announcer.spec.ts @@ -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: ``, + providers: [MdLiveAnnouncer], +}) +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..67c6b9a6a4ed --- /dev/null +++ b/src/core/live-announcer/live-announcer.ts @@ -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 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; + } + +} 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..b932ae84e377 --- /dev/null +++ b/src/demo-app/live-announcer/live-announcer-demo.ts @@ -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); + } + +} 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