Skip to content

Commit 3be4da6

Browse files
andrewseguinmmalerba
authored andcommitted
a11y: add service to add aria-describedby labels (#6168)
* checkin * changes * a11y(tooltip): add message element for tooltip a11y * comments * remove extra line * add test for tooltip message as number * remove fit * use renderer * always decrement * add aria-describer * add test * tests * md to cdk * Add aria-hidden to container * fix aot * comments * comments * rebase * fix prerender; add a11y docs
1 parent 426324b commit 3be4da6

File tree

11 files changed

+595
-20
lines changed

11 files changed

+595
-20
lines changed

src/cdk/a11y/aria-describer.spec.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import {A11yModule, CDK_DESCRIBEDBY_HOST_ATTRIBUTE} from './index';
2+
import {AriaDescriber, MESSAGES_CONTAINER_ID} from './aria-describer';
3+
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
4+
import {Component, ElementRef, ViewChild} from '@angular/core';
5+
6+
describe('AriaDescriber', () => {
7+
let ariaDescriber: AriaDescriber;
8+
let component: TestApp;
9+
let fixture: ComponentFixture<TestApp>;
10+
11+
beforeEach(async(() => {
12+
TestBed.configureTestingModule({
13+
imports: [A11yModule],
14+
declarations: [TestApp],
15+
providers: [AriaDescriber],
16+
}).compileComponents();
17+
}));
18+
19+
beforeEach(() => {
20+
fixture = TestBed.createComponent(TestApp);
21+
component = fixture.componentInstance;
22+
ariaDescriber = component.ariaDescriber;
23+
});
24+
25+
afterEach(() => {
26+
ariaDescriber.ngOnDestroy();
27+
});
28+
29+
it('should initialize without the message container', () => {
30+
expect(getMessagesContainer()).toBeNull();
31+
});
32+
33+
it('should be able to create a message element', () => {
34+
ariaDescriber.describe(component.element1, 'My Message');
35+
expectMessages(['My Message']);
36+
});
37+
38+
it('should not register empty strings', () => {
39+
ariaDescriber.describe(component.element1, '');
40+
expect(getMessageElements()).toBe(null);
41+
});
42+
43+
it('should de-dupe a message registered multiple times', () => {
44+
ariaDescriber.describe(component.element1, 'My Message');
45+
ariaDescriber.describe(component.element2, 'My Message');
46+
ariaDescriber.describe(component.element3, 'My Message');
47+
expectMessages(['My Message']);
48+
expectMessage(component.element1, 'My Message');
49+
expectMessage(component.element2, 'My Message');
50+
expectMessage(component.element3, 'My Message');
51+
});
52+
53+
it('should be able to register multiple messages', () => {
54+
ariaDescriber.describe(component.element1, 'First Message');
55+
ariaDescriber.describe(component.element2, 'Second Message');
56+
expectMessages(['First Message', 'Second Message']);
57+
expectMessage(component.element1, 'First Message');
58+
expectMessage(component.element2, 'Second Message');
59+
});
60+
61+
it('should be able to unregister messages', () => {
62+
ariaDescriber.describe(component.element1, 'My Message');
63+
expectMessages(['My Message']);
64+
65+
// Register again to check dedupe
66+
ariaDescriber.describe(component.element2, 'My Message');
67+
expectMessages(['My Message']);
68+
69+
// Unregister one message and make sure the message is still present in the container
70+
ariaDescriber.removeDescription(component.element1, 'My Message');
71+
expect(component.element1.hasAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE)).toBeFalsy();
72+
expectMessages(['My Message']);
73+
74+
// Unregister the second message, message container should be gone
75+
ariaDescriber.removeDescription(component.element2, 'My Message');
76+
expect(component.element2.hasAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE)).toBeFalsy();
77+
expect(getMessagesContainer()).toBeNull();
78+
});
79+
80+
it('should be able to unregister messages while having others registered', () => {
81+
ariaDescriber.describe(component.element1, 'Persistent Message');
82+
ariaDescriber.describe(component.element2, 'My Message');
83+
expectMessages(['Persistent Message', 'My Message']);
84+
85+
// Register again to check dedupe
86+
ariaDescriber.describe(component.element3, 'My Message');
87+
expectMessages(['Persistent Message', 'My Message']);
88+
89+
// Unregister one message and make sure the message is still present in the container
90+
ariaDescriber.removeDescription(component.element2, 'My Message');
91+
expectMessages(['Persistent Message', 'My Message']);
92+
93+
// Unregister the second message, message container should be gone
94+
ariaDescriber.removeDescription(component.element3, 'My Message');
95+
expectMessages(['Persistent Message']);
96+
});
97+
98+
it('should be able to append to an existing list of aria describedby', () => {
99+
ariaDescriber.describe(component.element4, 'My Message');
100+
expectMessages(['My Message']);
101+
expectMessage(component.element4, 'My Message');
102+
});
103+
104+
it('should be able to handle multiple regisitrations of the same message to an element', () => {
105+
ariaDescriber.describe(component.element1, 'My Message');
106+
ariaDescriber.describe(component.element1, 'My Message');
107+
expectMessages(['My Message']);
108+
expectMessage(component.element1, 'My Message');
109+
});
110+
});
111+
112+
function getMessagesContainer() {
113+
return document.querySelector(`#${MESSAGES_CONTAINER_ID}`);
114+
}
115+
116+
function getMessageElements(): Node[] | null {
117+
const messagesContainer = getMessagesContainer();
118+
if (!messagesContainer) { return null; }
119+
120+
return messagesContainer ? Array.prototype.slice.call(messagesContainer.children) : null;
121+
}
122+
123+
/** Checks that the messages array matches the existing created message elements. */
124+
function expectMessages(messages: string[]) {
125+
const messageElements = getMessageElements();
126+
expect(messageElements).toBeDefined();
127+
128+
expect(messages.length).toBe(messageElements!.length);
129+
messages.forEach((message, i) => {
130+
expect(messageElements![i].textContent).toBe(message);
131+
});
132+
}
133+
134+
/** Checks that an element points to a message element that contains the message. */
135+
function expectMessage(el: Element, message: string) {
136+
const ariaDescribedBy = el.getAttribute('aria-describedby');
137+
expect(ariaDescribedBy).toBeDefined();
138+
139+
const cdkDescribedBy = el.getAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE);
140+
expect(cdkDescribedBy).toBeDefined();
141+
142+
const messages = ariaDescribedBy!.split(' ').map(referenceId => {
143+
const messageElement = document.querySelector(`#${referenceId}`);
144+
return messageElement ? messageElement.textContent : '';
145+
});
146+
147+
expect(messages).toContain(message);
148+
}
149+
150+
@Component({
151+
template: `
152+
<div #element1></div>
153+
<div #element2></div>
154+
<div #element3></div>
155+
<div #element4 aria-describedby="existing-aria-describedby1 existing-aria-describedby2"></div>
156+
`,
157+
})
158+
class TestApp {
159+
@ViewChild('element1') _element1: ElementRef;
160+
get element1(): Element { return this._element1.nativeElement; }
161+
162+
@ViewChild('element2') _element2: ElementRef;
163+
get element2(): Element { return this._element2.nativeElement; }
164+
165+
@ViewChild('element3') _element3: ElementRef;
166+
get element3(): Element { return this._element3.nativeElement; }
167+
168+
@ViewChild('element4') _element4: ElementRef;
169+
get element4(): Element { return this._element4.nativeElement; }
170+
171+
172+
constructor(public ariaDescriber: AriaDescriber) { }
173+
}

src/cdk/a11y/aria-describer.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Injectable, Optional, SkipSelf} from '@angular/core';
10+
import {Platform} from '@angular/cdk/platform';
11+
import {addAriaReferencedId, getAriaReferenceIds, removeAriaReferencedId} from './aria-reference';
12+
13+
/**
14+
* Interface used to register message elements and keep a count of how many registrations have
15+
* the same message and the reference to the message element used for the aria-describedby.
16+
*/
17+
export interface RegisteredMessage {
18+
messageElement: Element;
19+
referenceCount: number;
20+
}
21+
22+
/** ID used for the body container where all messages are appended. */
23+
export const MESSAGES_CONTAINER_ID = 'cdk-describedby-message-container';
24+
25+
/** ID prefix used for each created message element. */
26+
export const CDK_DESCRIBEDBY_ID_PREFIX = 'cdk-describedby-message';
27+
28+
/** Attribute given to each host element that is described by a message element. */
29+
export const CDK_DESCRIBEDBY_HOST_ATTRIBUTE = 'cdk-describedby-host';
30+
31+
/** Global incremental identifier for each registered message element. */
32+
let nextId = 0;
33+
34+
/** Global map of all registered message elements that have been placed into the document. */
35+
const messageRegistry = new Map<string, RegisteredMessage>();
36+
37+
/** Container for all registered messages. */
38+
let messagesContainer: HTMLElement | null = null;
39+
40+
/**
41+
* Utility that creates visually hidden elements with a message content. Useful for elements that
42+
* want to use aria-describedby to further describe themselves without adding additional visual
43+
* content.
44+
* @docs-private
45+
*/
46+
@Injectable()
47+
export class AriaDescriber {
48+
constructor(private _platform: Platform) { }
49+
50+
/**
51+
* Adds to the host element an aria-describedby reference to a hidden element that contains
52+
* the message. If the same message has already been registered, then it will reuse the created
53+
* message element.
54+
*/
55+
describe(hostElement: Element, message: string) {
56+
if (!this._platform.isBrowser || !`${message}`.trim()) { return; }
57+
58+
if (!messageRegistry.has(message)) {
59+
createMessageElement(message);
60+
}
61+
62+
if (!isElementDescribedByMessage(hostElement, message)) {
63+
addMessageReference(hostElement, message);
64+
}
65+
}
66+
67+
/** Removes the host element's aria-describedby reference to the message element. */
68+
removeDescription(hostElement: Element, message: string) {
69+
if (!this._platform.isBrowser || !`${message}`.trim()) {
70+
return;
71+
}
72+
73+
if (isElementDescribedByMessage(hostElement, message)) {
74+
removeMessageReference(hostElement, message);
75+
}
76+
77+
if (messageRegistry.get(message)!.referenceCount === 0) {
78+
deleteMessageElement(message);
79+
}
80+
81+
if (messagesContainer!.childNodes.length === 0) {
82+
deleteMessagesContainer();
83+
}
84+
}
85+
86+
/** Unregisters all created message elements and removes the message container. */
87+
ngOnDestroy() {
88+
if (!this._platform.isBrowser) { return; }
89+
90+
const describedElements = document.querySelectorAll(`[${CDK_DESCRIBEDBY_HOST_ATTRIBUTE}]`);
91+
for (let i = 0; i < describedElements.length; i++) {
92+
removeCdkDescribedByReferenceIds(describedElements[i]);
93+
describedElements[i].removeAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE);
94+
}
95+
96+
if (messagesContainer) {
97+
deleteMessagesContainer();
98+
}
99+
100+
messageRegistry.clear();
101+
}
102+
}
103+
104+
/**
105+
* Creates a new element in the visually hidden message container element with the message
106+
* as its content and adds it to the message registry.
107+
*/
108+
function createMessageElement(message: string) {
109+
const messageElement = document.createElement('div');
110+
messageElement.setAttribute('id', `${CDK_DESCRIBEDBY_ID_PREFIX}-${nextId++}`);
111+
messageElement.appendChild(document.createTextNode(message)!);
112+
113+
if (!messagesContainer) { createMessagesContainer(); }
114+
messagesContainer!.appendChild(messageElement);
115+
116+
messageRegistry.set(message, {messageElement, referenceCount: 0});
117+
}
118+
119+
/** Deletes the message element from the global messages container. */
120+
function deleteMessageElement(message: string) {
121+
const messageElement = messageRegistry.get(message)!.messageElement;
122+
messagesContainer!.removeChild(messageElement);
123+
messageRegistry.delete(message);
124+
}
125+
126+
/** Creates the global container for all aria-describedby messages. */
127+
function createMessagesContainer() {
128+
messagesContainer = document.createElement('div');
129+
130+
messagesContainer.setAttribute('id', MESSAGES_CONTAINER_ID);
131+
messagesContainer.setAttribute('aria-hidden', 'true');
132+
messagesContainer.style.display = 'none';
133+
document.body.appendChild(messagesContainer);
134+
}
135+
136+
/** Deletes the global messages container. */
137+
function deleteMessagesContainer() {
138+
document.body.removeChild(messagesContainer!);
139+
messagesContainer = null;
140+
}
141+
142+
/** Removes all cdk-describedby messages that are hosted through the element. */
143+
function removeCdkDescribedByReferenceIds(element: Element) {
144+
// Remove all aria-describedby reference IDs that are prefixed by CDK_DESCRIBEDBY_ID_PREFIX
145+
const originalReferenceIds = getAriaReferenceIds(element, 'aria-describedby')
146+
.filter(id => id.indexOf(CDK_DESCRIBEDBY_ID_PREFIX) != 0);
147+
element.setAttribute('aria-describedby', originalReferenceIds.join(' '));
148+
}
149+
150+
/**
151+
* Adds a message reference to the element using aria-describedby and increments the registered
152+
* message's reference count.
153+
*/
154+
function addMessageReference(element: Element, message: string) {
155+
const registeredMessage = messageRegistry.get(message)!;
156+
157+
// Add the aria-describedby reference and set the describedby_host attribute to mark the element.
158+
addAriaReferencedId(element, 'aria-describedby', registeredMessage.messageElement.id);
159+
element.setAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE, '');
160+
161+
registeredMessage.referenceCount++;
162+
}
163+
164+
/**
165+
* Removes a message reference from the element using aria-describedby and decrements the registered
166+
* message's reference count.
167+
*/
168+
function removeMessageReference(element: Element, message: string) {
169+
const registeredMessage = messageRegistry.get(message)!;
170+
registeredMessage.referenceCount--;
171+
172+
removeAriaReferencedId(element, 'aria-describedby', registeredMessage.messageElement.id);
173+
element.removeAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE);
174+
}
175+
176+
/** Returns true if the element has been described by the provided message ID. */
177+
function isElementDescribedByMessage(element: Element, message: string) {
178+
const referenceIds = getAriaReferenceIds(element, 'aria-describedby');
179+
const messageId = messageRegistry.get(message)!.messageElement.id;
180+
181+
return referenceIds.indexOf(messageId) != -1;
182+
}
183+
184+
/** @docs-private */
185+
export function ARIA_DESCRIBER_PROVIDER_FACTORY(
186+
parentDispatcher: AriaDescriber, platform: Platform) {
187+
return parentDispatcher || new AriaDescriber(platform);
188+
}
189+
190+
/** @docs-private */
191+
export const ARIA_DESCRIBER_PROVIDER = {
192+
// If there is already an AriaDescriber available, use that. Otherwise, provide a new one.
193+
provide: AriaDescriber,
194+
deps: [
195+
[new Optional(), new SkipSelf(), AriaDescriber],
196+
Platform
197+
],
198+
useFactory: ARIA_DESCRIBER_PROVIDER_FACTORY
199+
};

0 commit comments

Comments
 (0)