Skip to content

Commit 8c4f25f

Browse files
crisbetojosephperrott
authored andcommitted
feat(a11y): allow for element to be used as message in AriaDescriber (#16118)
* Adds the ability to pass in an existing element as the message to `AriaDescriber.describe`. * Removes the `@docs-private` from the `AriaDescriber` so that it's available in the API docs. Fixes #16099.
1 parent f6903da commit 8c4f25f

File tree

3 files changed

+127
-18
lines changed

3 files changed

+127
-18
lines changed

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

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ describe('AriaDescriber', () => {
3636
expectMessages(['My Message']);
3737
});
3838

39+
it('should be able to describe using an element', () => {
40+
const descriptionNode = fixture.nativeElement.querySelector('#description-with-existing-id');
41+
ariaDescriber.describe(component.element1, descriptionNode);
42+
expectMessage(component.element1, 'Hello');
43+
});
44+
3945
it('should not register empty strings', () => {
4046
ariaDescriber.describe(component.element1, '');
4147
expect(getMessageElements()).toBe(null);
@@ -60,6 +66,16 @@ describe('AriaDescriber', () => {
6066
expectMessage(component.element3, 'My Message');
6167
});
6268

69+
it('should de-dupe a message registered multiple via an element node', () => {
70+
const descriptionNode = fixture.nativeElement.querySelector('#description-with-existing-id');
71+
ariaDescriber.describe(component.element1, descriptionNode);
72+
ariaDescriber.describe(component.element2, descriptionNode);
73+
ariaDescriber.describe(component.element3, descriptionNode);
74+
expectMessage(component.element1, 'Hello');
75+
expectMessage(component.element2, 'Hello');
76+
expectMessage(component.element3, 'Hello');
77+
});
78+
6379
it('should be able to register multiple messages', () => {
6480
ariaDescriber.describe(component.element1, 'First Message');
6581
ariaDescriber.describe(component.element2, 'Second Message');
@@ -87,6 +103,43 @@ describe('AriaDescriber', () => {
87103
expect(getMessagesContainer()).toBeNull();
88104
});
89105

106+
it('should not remove nodes that were set as messages when unregistering', () => {
107+
const descriptionNode = fixture.nativeElement.querySelector('#description-with-existing-id');
108+
109+
expect(document.body.contains(descriptionNode))
110+
.toBe(true, 'Expected node to be inside the document to begin with.');
111+
expect(getMessagesContainer()).toBeNull('Expected no messages container on init.');
112+
113+
ariaDescriber.describe(component.element1, descriptionNode);
114+
115+
expectMessage(component.element1, 'Hello');
116+
expect(getMessagesContainer())
117+
.toBeNull('Expected no messages container after the element was described.');
118+
119+
ariaDescriber.removeDescription(component.element1, descriptionNode);
120+
121+
expect(document.body.contains(descriptionNode)).toBe(true,
122+
'Expected description node to still be in the DOM after it is no longer being used.');
123+
});
124+
125+
it('should keep nodes set as descriptions inside their original position in the DOM', () => {
126+
const descriptionNode = fixture.nativeElement.querySelector('#description-with-existing-id');
127+
const initialParent = descriptionNode.parentNode;
128+
129+
expect(initialParent).toBeTruthy('Expected node to have a parent initially.');
130+
131+
ariaDescriber.describe(component.element1, descriptionNode);
132+
133+
expectMessage(component.element1, 'Hello');
134+
expect(descriptionNode.parentNode).toBe(initialParent,
135+
'Expected node to stay inside the same parent when used as a description.');
136+
137+
ariaDescriber.removeDescription(component.element1, descriptionNode);
138+
139+
expect(descriptionNode.parentNode).toBe(initialParent,
140+
'Expected node to stay inside the same parent after not being used as a description.');
141+
});
142+
90143
it('should be able to unregister messages while having others registered', () => {
91144
ariaDescriber.describe(component.element1, 'Persistent Message');
92145
ariaDescriber.describe(component.element2, 'My Message');
@@ -140,6 +193,39 @@ describe('AriaDescriber', () => {
140193
ariaDescriber.describe(component.element1, 'Hi');
141194
expectMessages(['Hi']);
142195
});
196+
197+
it('should assign an id to the description element, if it does not have one', () => {
198+
const descriptionNode = fixture.nativeElement.querySelector('[description-without-id]');
199+
expect(descriptionNode.getAttribute('id')).toBeFalsy();
200+
ariaDescriber.describe(component.element1, descriptionNode);
201+
expect(descriptionNode.getAttribute('id')).toBeTruthy();
202+
});
203+
204+
it('should not overwrite the existing id of the description element', () => {
205+
const descriptionNode = fixture.nativeElement.querySelector('#description-with-existing-id');
206+
expect(descriptionNode.id).toBe('description-with-existing-id');
207+
ariaDescriber.describe(component.element1, descriptionNode);
208+
expect(descriptionNode.id).toBe('description-with-existing-id');
209+
});
210+
211+
it('should not remove pre-existing description nodes on destroy', () => {
212+
const descriptionNode = fixture.nativeElement.querySelector('#description-with-existing-id');
213+
214+
expect(document.body.contains(descriptionNode))
215+
.toBe(true, 'Expected node to be inside the document to begin with.');
216+
217+
ariaDescriber.describe(component.element1, descriptionNode);
218+
219+
expectMessage(component.element1, 'Hello');
220+
expect(component.element1.hasAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE)).toBe(true);
221+
222+
ariaDescriber.ngOnDestroy();
223+
224+
expect(component.element1.hasAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE)).toBe(false);
225+
expect(document.body.contains(descriptionNode)).toBe(true,
226+
'Expected description node to still be in the DOM after it is no longer being used.');
227+
});
228+
143229
});
144230

145231
function getMessagesContainer() {
@@ -186,6 +272,8 @@ function expectMessage(el: Element, message: string) {
186272
<div #element2></div>
187273
<div #element3></div>
188274
<div #element4 aria-describedby="existing-aria-describedby1 existing-aria-describedby2"></div>
275+
<div id="description-with-existing-id">Hello</div>
276+
<div description-without-id>Hey</div>
189277
`,
190278
})
191279
class TestApp {

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

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export const CDK_DESCRIBEDBY_HOST_ATTRIBUTE = 'cdk-describedby-host';
4343
let nextId = 0;
4444

4545
/** Global map of all registered message elements that have been placed into the document. */
46-
const messageRegistry = new Map<string, RegisteredMessage>();
46+
const messageRegistry = new Map<string|HTMLElement, RegisteredMessage>();
4747

4848
/** Container for all registered messages. */
4949
let messagesContainer: HTMLElement | null = null;
@@ -52,7 +52,6 @@ let messagesContainer: HTMLElement | null = null;
5252
* Utility that creates visually hidden elements with a message content. Useful for elements that
5353
* want to use aria-describedby to further describe themselves without adding additional visual
5454
* content.
55-
* @docs-private
5655
*/
5756
@Injectable({providedIn: 'root'})
5857
export class AriaDescriber implements OnDestroy {
@@ -67,12 +66,16 @@ export class AriaDescriber implements OnDestroy {
6766
* the message. If the same message has already been registered, then it will reuse the created
6867
* message element.
6968
*/
70-
describe(hostElement: Element, message: string) {
69+
describe(hostElement: Element, message: string|HTMLElement) {
7170
if (!this._canBeDescribed(hostElement, message)) {
7271
return;
7372
}
7473

75-
if (!messageRegistry.has(message)) {
74+
if (typeof message !== 'string') {
75+
// We need to ensure that the element has an ID.
76+
this._setMessageId(message);
77+
messageRegistry.set(message, {messageElement: message, referenceCount: 0});
78+
} else if (!messageRegistry.has(message)) {
7679
this._createMessageElement(message);
7780
}
7881

@@ -82,7 +85,7 @@ export class AriaDescriber implements OnDestroy {
8285
}
8386

8487
/** Removes the host element's aria-describedby reference to the message element. */
85-
removeDescription(hostElement: Element, message: string) {
88+
removeDescription(hostElement: Element, message: string|HTMLElement) {
8689
if (!this._isElementNode(hostElement)) {
8790
return;
8891
}
@@ -91,9 +94,13 @@ export class AriaDescriber implements OnDestroy {
9194
this._removeMessageReference(hostElement, message);
9295
}
9396

94-
const registeredMessage = messageRegistry.get(message);
95-
if (registeredMessage && registeredMessage.referenceCount === 0) {
96-
this._deleteMessageElement(message);
97+
// If the message is a string, it means that it's one that we created for the
98+
// consumer so we can remove it safely, otherwise we should leave it in place.
99+
if (typeof message === 'string') {
100+
const registeredMessage = messageRegistry.get(message);
101+
if (registeredMessage && registeredMessage.referenceCount === 0) {
102+
this._deleteMessageElement(message);
103+
}
97104
}
98105

99106
if (messagesContainer && messagesContainer.childNodes.length === 0) {
@@ -124,15 +131,22 @@ export class AriaDescriber implements OnDestroy {
124131
*/
125132
private _createMessageElement(message: string) {
126133
const messageElement = this._document.createElement('div');
127-
messageElement.setAttribute('id', `${CDK_DESCRIBEDBY_ID_PREFIX}-${nextId++}`);
128-
messageElement.appendChild(this._document.createTextNode(message)!);
134+
this._setMessageId(messageElement);
135+
messageElement.textContent = message;
129136

130137
this._createMessagesContainer();
131138
messagesContainer!.appendChild(messageElement);
132139

133140
messageRegistry.set(message, {messageElement, referenceCount: 0});
134141
}
135142

143+
/** Assigns a unique ID to an element, if it doesn't have one already. */
144+
private _setMessageId(element: HTMLElement) {
145+
if (!element.id) {
146+
element.id = `${CDK_DESCRIBEDBY_ID_PREFIX}-${nextId++}`;
147+
}
148+
}
149+
136150
/** Deletes the message element from the global messages container. */
137151
private _deleteMessageElement(message: string) {
138152
const registeredMessage = messageRegistry.get(message);
@@ -184,7 +198,7 @@ export class AriaDescriber implements OnDestroy {
184198
* Adds a message reference to the element using aria-describedby and increments the registered
185199
* message's reference count.
186200
*/
187-
private _addMessageReference(element: Element, message: string) {
201+
private _addMessageReference(element: Element, message: string|HTMLElement) {
188202
const registeredMessage = messageRegistry.get(message)!;
189203

190204
// Add the aria-describedby reference and set the
@@ -199,7 +213,7 @@ export class AriaDescriber implements OnDestroy {
199213
* Removes a message reference from the element using aria-describedby
200214
* and decrements the registered message's reference count.
201215
*/
202-
private _removeMessageReference(element: Element, message: string) {
216+
private _removeMessageReference(element: Element, message: string|HTMLElement) {
203217
const registeredMessage = messageRegistry.get(message)!;
204218
registeredMessage.referenceCount--;
205219

@@ -208,7 +222,7 @@ export class AriaDescriber implements OnDestroy {
208222
}
209223

210224
/** Returns true if the element has been described by the provided message ID. */
211-
private _isElementDescribedByMessage(element: Element, message: string): boolean {
225+
private _isElementDescribedByMessage(element: Element, message: string|HTMLElement): boolean {
212226
const referenceIds = getAriaReferenceIds(element, 'aria-describedby');
213227
const registeredMessage = messageRegistry.get(message);
214228
const messageId = registeredMessage && registeredMessage.messageElement.id;
@@ -217,16 +231,23 @@ export class AriaDescriber implements OnDestroy {
217231
}
218232

219233
/** Determines whether a message can be described on a particular element. */
220-
private _canBeDescribed(element: Element, message: string): boolean {
234+
private _canBeDescribed(element: Element, message: string|HTMLElement|void): boolean {
221235
if (!this._isElementNode(element)) {
222236
return false;
223237
}
224238

239+
if (message && typeof message === 'object') {
240+
// We'd have to make some assumptions about the description element's text, if the consumer
241+
// passed in an element. Assume that if an element is passed in, the consumer has verified
242+
// that it can be used as a description.
243+
return true;
244+
}
245+
225246
const trimmedMessage = message == null ? '' : `${message}`.trim();
226247
const ariaLabel = element.getAttribute('aria-label');
227248

228-
// We shouldn't set descriptions if they're exactly the same as the `aria-label` of the element,
229-
// because screen readers will end up reading out the same text twice in a row.
249+
// We shouldn't set descriptions if they're exactly the same as the `aria-label` of the
250+
// element, because screen readers will end up reading out the same text twice in a row.
230251
return trimmedMessage ? (!ariaLabel || ariaLabel.trim() !== trimmedMessage) : false;
231252
}
232253

tools/public_api_guard/cdk/a11y.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ export declare function ARIA_DESCRIBER_PROVIDER_FACTORY(parentDispatcher: AriaDe
1616

1717
export declare class AriaDescriber implements OnDestroy {
1818
constructor(_document: any);
19-
describe(hostElement: Element, message: string): void;
19+
describe(hostElement: Element, message: string | HTMLElement): void;
2020
ngOnDestroy(): void;
21-
removeDescription(hostElement: Element, message: string): void;
21+
removeDescription(hostElement: Element, message: string | HTMLElement): void;
2222
}
2323

2424
export declare type AriaLivePoliteness = 'off' | 'polite' | 'assertive';

0 commit comments

Comments
 (0)