mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
committed by
Jeremy Elbourn
parent
82a22a71d0
commit
e99da663f7
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
@import 'mixins';
|
||||
|
||||
.md-live-announcer {
|
||||
@include md-visually-hidden();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
<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()}}
|
||||
|
||||
@@ -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({
|
||||
@@ -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 { }
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<div class="demo-live-announcer">
|
||||
|
||||
<button md-button (click)="announceText('Hey Google')">Announce Text</button>
|
||||
|
||||
</div>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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()}),
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user