From 62faa49d43d98c8dfe2d70d78a56413dddc3db62 Mon Sep 17 00:00:00 2001 From: Paul Gschwendtner Date: Sat, 2 Apr 2016 10:15:49 +0200 Subject: [PATCH] feat(): add aria live announcer Fixes #106 --- src/components/live-announcer/README.md | 37 ++++++ .../live-announcer/live-announcer.html | 2 + .../live-announcer/live-announcer.scss | 9 ++ .../live-announcer/live-announcer.spec.ts | 119 ++++++++++++++++++ .../live-announcer/live-announcer.ts | 36 ++++++ 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 | 9 ++ 10 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 src/components/live-announcer/README.md create mode 100644 src/components/live-announcer/live-announcer.html create mode 100644 src/components/live-announcer/live-announcer.scss create mode 100644 src/components/live-announcer/live-announcer.spec.ts create mode 100644 src/components/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/components/live-announcer/README.md b/src/components/live-announcer/README.md new file mode 100644 index 000000000000..8da42f4da966 --- /dev/null +++ b/src/components/live-announcer/README.md @@ -0,0 +1,37 @@ +# MdAriaLive +`MdAriaLive` 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/components/live-announcer/live-announcer.html b/src/components/live-announcer/live-announcer.html new file mode 100644 index 000000000000..8b6fd7154eae --- /dev/null +++ b/src/components/live-announcer/live-announcer.html @@ -0,0 +1,2 @@ + +
\ No newline at end of file diff --git a/src/components/live-announcer/live-announcer.scss b/src/components/live-announcer/live-announcer.scss new file mode 100644 index 000000000000..c8032440cb5d --- /dev/null +++ b/src/components/live-announcer/live-announcer.scss @@ -0,0 +1,9 @@ +@import 'mixins'; + +:host { + + .md-live-announcer { + @include md-visually-hidden(); + } + +} \ No newline at end of file diff --git a/src/components/live-announcer/live-announcer.spec.ts b/src/components/live-announcer/live-announcer.spec.ts new file mode 100644 index 000000000000..ad013cb02273 --- /dev/null +++ b/src/components/live-announcer/live-announcer.spec.ts @@ -0,0 +1,119 @@ +import { + inject, + TestComponentBuilder, + ComponentFixture, + fakeAsync, + flushMicrotasks, + tick +} 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 builder: TestComponentBuilder; + + beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => { + builder = tcb; + })); + + it('should correctly update the announce text', fakeAsyncTest(() => { + let appFixture: ComponentFixture = null; + + builder.createAsync(TestApp).then(fixture => { + appFixture = fixture; + }); + + flushMicrotasks(); + + let announcerElement = appFixture.debugElement + .query(By.css('.md-live-announcer')).nativeElement; + let buttonElement = appFixture.debugElement + .query(By.css('button')).nativeElement; + + appFixture.detectChanges(); + + buttonElement.click(); + + // This flushes our 100ms timeout for the screenreaders. + tick(100); + + expect(announcerElement.textContent).toBe('Test'); + })); + + it('should correctly update the politeness attribute', fakeAsyncTest(() => { + let appFixture: ComponentFixture = null; + + builder.createAsync(TestApp).then(fixture => { + appFixture = fixture; + }); + + flushMicrotasks(); + + let live: MdLiveAnnouncer = appFixture.debugElement + .query(By.css('md-live-announcer')).componentInstance; + let announcerElement = appFixture.debugElement + .query(By.css('.md-live-announcer')).nativeElement; + + appFixture.detectChanges(); + + live.announce('Hey Google', 'assertive'); + + // This flushes our 100ms timeout for the screenreaders. + tick(100); + + expect(announcerElement.textContent).toBe('Hey Google'); + expect(announcerElement.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(); + + let live: MdLiveAnnouncer = appFixture.debugElement + .query(By.css('md-live-announcer')).componentInstance; + let announcerElement = appFixture.debugElement + .query(By.css('.md-live-announcer')).nativeElement; + + appFixture.detectChanges(); + + live.announce('Hey Google'); + + // This flushes our 100ms timeout for the screenreaders. + tick(100); + + expect(announcerElement.textContent).toBe('Hey Google'); + expect(announcerElement.getAttribute('aria-live')).toBe('polite'); + })); + + }); +} + +function fakeAsyncTest(fn: () => void) { + return inject([], fakeAsync(fn)); +} + +@Component({ + selector: 'test-app', + template: ` + + + + `, + directives: [MdLiveAnnouncer], +}) +class TestApp { +} + diff --git a/src/components/live-announcer/live-announcer.ts b/src/components/live-announcer/live-announcer.ts new file mode 100644 index 000000000000..df1028962f62 --- /dev/null +++ b/src/components/live-announcer/live-announcer.ts @@ -0,0 +1,36 @@ +import { + Component, + ElementRef, + AfterContentInit +} from 'angular2/core'; + +import {DOM} from '../../core/platform/dom/dom_adapter'; + +@Component({ + selector: 'md-live-announcer', + templateUrl: './components/live-announcer/live-announcer.html', + styleUrls: ['./components/live-announcer/live-announcer.css'], +}) +export class MdLiveAnnouncer implements AfterContentInit { + + private announcerEl: HTMLElement; + + constructor(private elementRef: ElementRef) { } + + ngAfterContentInit() { + this.announcerEl = DOM.querySelector(this.elementRef.nativeElement, '.md-live-announcer'); + } + + 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); + } +} 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..51e7c8839ac7 --- /dev/null +++ b/src/demo-app/live-announcer/live-announcer-demo.ts @@ -0,0 +1,9 @@ +import {Component} from 'angular2/core'; +import {MdLiveAnnouncer} from '../../components/live-announcer/live-announcer'; + +@Component({ + selector: 'toolbar-demo', + templateUrl: 'demo-app/live-announcer/live-announcer-demo.html', + directives: [MdLiveAnnouncer] +}) +export class LiveAnnouncerDemo {}