Skip to content

Commit

Permalink
fix(dom): Support providing an owner document for announcer messages.
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 393135566
  • Loading branch information
material-web-copybara authored and Copybara-Service committed Aug 26, 2021
1 parent 877e3fb commit 6236f35
Show file tree
Hide file tree
Showing 3 changed files with 77 additions and 28 deletions.
2 changes: 1 addition & 1 deletion packages/mdc-dom/README.md
Expand Up @@ -60,7 +60,7 @@ The `announce` utility file contains a single helper method for announcing a mes

Method Signature | Description
--- | ---
`announce(message: string, priority?: AnnouncerPriority) => void` | Announces the message via an `aria-live` region with the given priority (defaults to polite)
`announce(message: string, options?: AnnouncerMessageOptions) => void` | Announces the message via an `aria-live` region with the given options. `AnnouncerMessageOptions.priority` defaults to polite and `AnnouncerMessageOptions.ownerDocument` defaults to the global document.
<!-- TODO(b/148462294): Remove once only exported members are required in docs `say()` --> <!-- | --> <!-- DO NOT USE -->

## Keyboard
Expand Down
50 changes: 34 additions & 16 deletions packages/mdc-dom/announce.ts
Expand Up @@ -22,13 +22,21 @@
*/

/**
* Priorities for the announce function
* Priorities for the announce function.
*/
export enum AnnouncerPriority {
POLITE = 'polite',
ASSERTIVE = 'assertive',
}

/**
* Options for the announce function.
*/
export interface AnnouncerMessageOptions {
priority?: AnnouncerPriority;
ownerDocument?: Document;
}

/**
* Data attribute added to live region element.
*/
Expand All @@ -37,13 +45,13 @@ export const DATA_MDC_DOM_ANNOUNCE = 'data-mdc-dom-announce';
/**
* Announces the given message with optional priority, defaulting to "polite"
*/
export function announce(message: string, priority?: AnnouncerPriority) {
Announcer.getInstance().say(message, priority);
export function announce(message: string, options?: AnnouncerMessageOptions) {
Announcer.getInstance().say(message, options);
}

class Announcer {
private static instance: Announcer;
private readonly liveRegions: Map<AnnouncerPriority, Element>;
private readonly liveRegions: Map<Document, Map<AnnouncerPriority, Element>>;

static getInstance(): Announcer {
if (!Announcer.instance) {
Expand All @@ -58,37 +66,47 @@ class Announcer {
this.liveRegions = new Map();
}

say(message: string, priority: AnnouncerPriority = AnnouncerPriority.POLITE) {
const liveRegion = this.getLiveRegion(priority);
say(message: string, options?: AnnouncerMessageOptions) {
const priority = options?.priority ?? AnnouncerPriority.POLITE;
const ownerDocument = options?.ownerDocument ?? document;
const liveRegion = this.getLiveRegion(priority, ownerDocument);
// Reset the region to pick up the message, even if the message is the
// exact same as before.
liveRegion.textContent = '';
// Timeout is necessary for screen readers like NVDA and VoiceOver.
setTimeout(() => {
liveRegion.textContent = message;
document.addEventListener('click', clearLiveRegion);
ownerDocument.addEventListener('click', clearLiveRegion);
}, 1);

function clearLiveRegion() {
liveRegion.textContent = '';
document.removeEventListener('click', clearLiveRegion);
ownerDocument.removeEventListener('click', clearLiveRegion);
}
}

private getLiveRegion(priority: AnnouncerPriority): Element {
const existingLiveRegion = this.liveRegions.get(priority);
private getLiveRegion(priority: AnnouncerPriority, ownerDocument: Document):
Element {
let documentLiveRegions = this.liveRegions.get(ownerDocument);
if (!documentLiveRegions) {
documentLiveRegions = new Map();
this.liveRegions.set(ownerDocument, documentLiveRegions);
}

const existingLiveRegion = documentLiveRegions.get(priority);
if (existingLiveRegion &&
document.body.contains(existingLiveRegion as Node)) {
ownerDocument.body.contains(existingLiveRegion as Node)) {
return existingLiveRegion;
}

const liveRegion = this.createLiveRegion(priority);
this.liveRegions.set(priority, liveRegion);
const liveRegion = this.createLiveRegion(priority, ownerDocument);
documentLiveRegions.set(priority, liveRegion);
return liveRegion;
}

private createLiveRegion(priority: AnnouncerPriority): Element {
const el = document.createElement('div');
private createLiveRegion(
priority: AnnouncerPriority, ownerDocument: Document): Element {
const el = ownerDocument.createElement('div');
el.style.position = 'absolute';
el.style.top = '-9999px';
el.style.left = '-9999px';
Expand All @@ -97,7 +115,7 @@ class Announcer {
el.setAttribute('aria-atomic', 'true');
el.setAttribute('aria-live', priority);
el.setAttribute(DATA_MDC_DOM_ANNOUNCE, 'true');
document.body.appendChild(el);
ownerDocument.body.appendChild(el);
return el;
}
}
53 changes: 42 additions & 11 deletions packages/mdc-dom/test/announce.test.ts
Expand Up @@ -46,12 +46,23 @@ describe('announce', () => {
});

it('creates an aria-live="assertive" region if specified', () => {
announce('Bar', AnnouncerPriority.ASSERTIVE);
announce('Bar', {priority: AnnouncerPriority.ASSERTIVE});
jasmine.clock().tick(1);
const liveRegion = document.querySelector(LIVE_REGION_SELECTOR);
expect(liveRegion!.textContent).toEqual('Bar');
});

it('uses the provided ownerDocument for announcements', () => {
const ownerDocument = document.implementation.createHTMLDocument('Title');
announce('custom ownerDocument', {ownerDocument});
const globalDocumentLiveRegion =
document.querySelector(LIVE_REGION_SELECTOR);
expect(globalDocumentLiveRegion).toBeNull();
const ownerDocumentLiveRegion =
ownerDocument.querySelector(LIVE_REGION_SELECTOR);
expect(ownerDocumentLiveRegion).toBeDefined();
});

it('sets live region content after a timeout', () => {
announce('Baz');
const liveRegion = document.querySelector(LIVE_REGION_SELECTOR);
Expand All @@ -60,22 +71,42 @@ describe('announce', () => {
expect(liveRegion!.textContent).toEqual('Baz');
});

it('reuses same polite live region on successive calls', () => {
it('reuses same polite live region on successive calls per document', () => {
const secondDocument = document.implementation.createHTMLDocument('Title');
announce('aaa');
announce('aaa', {ownerDocument: secondDocument});
announce('bbb');
announce('bbb', {ownerDocument: secondDocument});
announce('ccc');
const liveRegions = document.querySelectorAll(LIVE_REGION_SELECTOR);
expect(liveRegions.length).toEqual(1);
});
announce('ccc', {ownerDocument: secondDocument});

it('reuses same assertive live region on successive calls', () => {
announce('aaa', AnnouncerPriority.ASSERTIVE);
announce('bbb', AnnouncerPriority.ASSERTIVE);
announce('ccc', AnnouncerPriority.ASSERTIVE);
const liveRegions = document.querySelectorAll(LIVE_REGION_SELECTOR);
expect(liveRegions.length).toEqual(1);
const globalDocumentLiveRegions =
document.querySelectorAll(LIVE_REGION_SELECTOR);
expect(globalDocumentLiveRegions.length).toEqual(1);
const secondDocumentLiveRegions =
secondDocument.querySelectorAll(LIVE_REGION_SELECTOR);
expect(secondDocumentLiveRegions.length).toEqual(1);
});

it('reuses same assertive live region on successive calls per document',
() => {
const secondDocument =
document.implementation.createHTMLDocument('Title');
announce('aaa', {priority: AnnouncerPriority.ASSERTIVE});
announce('aaa', {priority: AnnouncerPriority.ASSERTIVE, ownerDocument: secondDocument});
announce('bbb', {priority: AnnouncerPriority.ASSERTIVE});
announce('bbb', {priority: AnnouncerPriority.ASSERTIVE, ownerDocument: secondDocument});
announce('ccc', {priority: AnnouncerPriority.ASSERTIVE});
announce('ccc', {priority: AnnouncerPriority.ASSERTIVE, ownerDocument: secondDocument});

const globalDocumentLiveRegions =
document.querySelectorAll(LIVE_REGION_SELECTOR);
expect(globalDocumentLiveRegions.length).toEqual(1);
const secondDocumentLiveRegions =
secondDocument.querySelectorAll(LIVE_REGION_SELECTOR);
expect(secondDocumentLiveRegions.length).toEqual(1);
});

it('sets the latest message during immediate successive', () => {
announce('1');
announce('2');
Expand Down

0 comments on commit 6236f35

Please sign in to comment.