Skip to content

Commit fec67cd

Browse files
crisbetowagnermaciel
authored andcommitted
feat(cdk/a11y): support setting a role through AriaDescriber
Allows for an optional `role` to be assigned to the description element. This is required for some cases like tooltips (see #20593).
1 parent 94bd0d4 commit fec67cd

File tree

3 files changed

+123
-40
lines changed

3 files changed

+123
-40
lines changed

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

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -286,17 +286,71 @@ describe('AriaDescriber', () => {
286286
ariaDescriber.describe(component.element1, 'My Message');
287287
expect(getMessagesContainer().getAttribute('aria-hidden')).toBe('false');
288288
});
289+
290+
it('should be able to register the same message with different roles', () => {
291+
createFixture();
292+
ariaDescriber.describe(component.element1, 'My Message', 'tooltip');
293+
ariaDescriber.describe(component.element2, 'My Message', 'button');
294+
ariaDescriber.describe(component.element3, 'My Message', 'presentation');
295+
expectMessages(['tooltip/My Message', 'button/My Message', 'presentation/My Message']);
296+
expectMessage(component.element1, 'tooltip/My Message');
297+
expectMessage(component.element2, 'button/My Message');
298+
expectMessage(component.element3, 'presentation/My Message');
299+
});
300+
301+
it('should de-dupe a message if it has been registered with the same role', () => {
302+
createFixture();
303+
ariaDescriber.describe(component.element1, 'My Message', 'tooltip');
304+
ariaDescriber.describe(component.element2, 'My Message', 'tooltip');
305+
ariaDescriber.describe(component.element3, 'My Message', 'tooltip');
306+
expectMessages(['tooltip/My Message']);
307+
expectMessage(component.element1, 'tooltip/My Message');
308+
expectMessage(component.element2, 'tooltip/My Message');
309+
expectMessage(component.element3, 'tooltip/My Message');
310+
});
311+
312+
it('should be able to unregister messages with a particular role', () => {
313+
createFixture();
314+
ariaDescriber.describe(component.element1, 'My Message', 'tooltip');
315+
expectMessages(['tooltip/My Message']);
316+
317+
// Register again to check dedupe
318+
ariaDescriber.describe(component.element2, 'My Message', 'tooltip');
319+
expectMessages(['tooltip/My Message']);
320+
321+
// Unregister one message and make sure the message is still present in the container
322+
ariaDescriber.removeDescription(component.element1, 'My Message', 'tooltip');
323+
expect(component.element1.hasAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE)).toBeFalsy();
324+
expectMessages(['tooltip/My Message']);
325+
326+
// Unregister the second message, message container should be gone
327+
ariaDescriber.removeDescription(component.element2, 'My Message', 'tooltip');
328+
expect(component.element2.hasAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE)).toBeFalsy();
329+
expect(getMessagesContainer()).toBeNull();
330+
});
331+
332+
it('should not remove element if it is registered with same text, but different role', () => {
333+
createFixture();
334+
ariaDescriber.describe(component.element1, 'My Message', 'tooltip');
335+
ariaDescriber.describe(component.element2, 'My Message', 'button');
336+
expectMessages(['tooltip/My Message', 'button/My Message']);
337+
ariaDescriber.removeDescription(component.element2, 'My Message', 'button');
338+
expectMessages(['tooltip/My Message']);
339+
ariaDescriber.removeDescription(component.element1, 'My Message', 'tooltip');
340+
expect(getMessageElements()).toBeNull();
341+
});
342+
289343
});
290344

291345
function getMessagesContainer() {
292346
return document.querySelector(`#${MESSAGES_CONTAINER_ID}`)!;
293347
}
294348

295-
function getMessageElements(): Node[] | null {
349+
function getMessageElements(): Element[] | null {
296350
const messagesContainer = getMessagesContainer();
297351
if (!messagesContainer) { return null; }
298352

299-
return messagesContainer ? Array.prototype.slice.call(messagesContainer.children) : null;
353+
return messagesContainer ? Array.prototype.slice.call(messagesContainer.children) : null;
300354
}
301355

302356
/** Checks that the messages array matches the existing created message elements. */
@@ -306,7 +360,9 @@ function expectMessages(messages: string[]) {
306360

307361
expect(messages.length).toBe(messageElements!.length);
308362
messages.forEach((message, i) => {
309-
expect(messageElements![i].textContent).toBe(message);
363+
const element = messageElements![i];
364+
const role = element.getAttribute('role');
365+
expect((role ? role + '/' : '') + element.textContent).toBe(message);
310366
});
311367
}
312368

@@ -320,7 +376,9 @@ function expectMessage(el: Element, message: string) {
320376

321377
const messages = ariaDescribedBy!.split(' ').map(referenceId => {
322378
const messageElement = document.querySelector(`#${referenceId}`);
323-
return messageElement ? messageElement.textContent : '';
379+
const role = messageElement?.getAttribute('role');
380+
const prefix = role ? role + '/' : '';
381+
return messageElement ? prefix + messageElement.textContent : '';
324382
});
325383

326384
expect(messages).toContain(message);

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

Lines changed: 57 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const CDK_DESCRIBEDBY_HOST_ATTRIBUTE = 'cdk-describedby-host';
3737
let nextId = 0;
3838

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

4242
/** Container for all registered messages. */
4343
let messagesContainer: HTMLElement | null = null;
@@ -65,40 +65,56 @@ export class AriaDescriber implements OnDestroy {
6565
* the message. If the same message has already been registered, then it will reuse the created
6666
* message element.
6767
*/
68-
describe(hostElement: Element, message: string|HTMLElement) {
68+
describe(hostElement: Element, message: string, role?: string): void;
69+
70+
/**
71+
* Adds to the host element an aria-describedby reference to an already-existing messsage element.
72+
*/
73+
describe(hostElement: Element, message: HTMLElement): void;
74+
75+
describe(hostElement: Element, message: string|HTMLElement, role?: string): void {
6976
if (!this._canBeDescribed(hostElement, message)) {
7077
return;
7178
}
7279

80+
const key = getKey(message, role);
81+
7382
if (typeof message !== 'string') {
7483
// We need to ensure that the element has an ID.
75-
this._setMessageId(message);
76-
messageRegistry.set(message, {messageElement: message, referenceCount: 0});
77-
} else if (!messageRegistry.has(message)) {
78-
this._createMessageElement(message);
84+
setMessageId(message);
85+
messageRegistry.set(key, {messageElement: message, referenceCount: 0});
86+
} else if (!messageRegistry.has(key)) {
87+
this._createMessageElement(message, role);
7988
}
8089

81-
if (!this._isElementDescribedByMessage(hostElement, message)) {
82-
this._addMessageReference(hostElement, message);
90+
if (!this._isElementDescribedByMessage(hostElement, key)) {
91+
this._addMessageReference(hostElement, key);
8392
}
8493
}
8594

95+
/** Removes the host element's aria-describedby reference to the message. */
96+
removeDescription(hostElement: Element, message: string, role?: string): void;
97+
8698
/** Removes the host element's aria-describedby reference to the message element. */
87-
removeDescription(hostElement: Element, message: string|HTMLElement) {
99+
removeDescription(hostElement: Element, message: HTMLElement): void;
100+
101+
removeDescription(hostElement: Element, message: string|HTMLElement, role?: string): void {
88102
if (!message || !this._isElementNode(hostElement)) {
89103
return;
90104
}
91105

92-
if (this._isElementDescribedByMessage(hostElement, message)) {
93-
this._removeMessageReference(hostElement, message);
106+
const key = getKey(message, role);
107+
108+
if (this._isElementDescribedByMessage(hostElement, key)) {
109+
this._removeMessageReference(hostElement, key);
94110
}
95111

96112
// If the message is a string, it means that it's one that we created for the
97113
// consumer so we can remove it safely, otherwise we should leave it in place.
98114
if (typeof message === 'string') {
99-
const registeredMessage = messageRegistry.get(message);
115+
const registeredMessage = messageRegistry.get(key);
100116
if (registeredMessage && registeredMessage.referenceCount === 0) {
101-
this._deleteMessageElement(message);
117+
this._deleteMessageElement(key);
102118
}
103119
}
104120

@@ -128,32 +144,28 @@ export class AriaDescriber implements OnDestroy {
128144
* Creates a new element in the visually hidden message container element with the message
129145
* as its content and adds it to the message registry.
130146
*/
131-
private _createMessageElement(message: string) {
147+
private _createMessageElement(message: string, role?: string) {
132148
const messageElement = this._document.createElement('div');
133-
this._setMessageId(messageElement);
149+
setMessageId(messageElement);
134150
messageElement.textContent = message;
135151

152+
if (role) {
153+
messageElement.setAttribute('role', role);
154+
}
155+
136156
this._createMessagesContainer();
137157
messagesContainer!.appendChild(messageElement);
138-
139-
messageRegistry.set(message, {messageElement, referenceCount: 0});
140-
}
141-
142-
/** Assigns a unique ID to an element, if it doesn't have one already. */
143-
private _setMessageId(element: HTMLElement) {
144-
if (!element.id) {
145-
element.id = `${CDK_DESCRIBEDBY_ID_PREFIX}-${nextId++}`;
146-
}
158+
messageRegistry.set(getKey(message, role), {messageElement, referenceCount: 0});
147159
}
148160

149161
/** Deletes the message element from the global messages container. */
150-
private _deleteMessageElement(message: string) {
151-
const registeredMessage = messageRegistry.get(message);
162+
private _deleteMessageElement(key: string|Element) {
163+
const registeredMessage = messageRegistry.get(key);
152164
const messageElement = registeredMessage && registeredMessage.messageElement;
153165
if (messagesContainer && messageElement) {
154166
messagesContainer.removeChild(messageElement);
155167
}
156-
messageRegistry.delete(message);
168+
messageRegistry.delete(key);
157169
}
158170

159171
/** Creates the global container for all aria-describedby messages. */
@@ -204,33 +216,32 @@ export class AriaDescriber implements OnDestroy {
204216
* Adds a message reference to the element using aria-describedby and increments the registered
205217
* message's reference count.
206218
*/
207-
private _addMessageReference(element: Element, message: string|HTMLElement) {
208-
const registeredMessage = messageRegistry.get(message)!;
219+
private _addMessageReference(element: Element, key: string|Element) {
220+
const registeredMessage = messageRegistry.get(key)!;
209221

210222
// Add the aria-describedby reference and set the
211223
// describedby_host attribute to mark the element.
212224
addAriaReferencedId(element, 'aria-describedby', registeredMessage.messageElement.id);
213225
element.setAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE, '');
214-
215226
registeredMessage.referenceCount++;
216227
}
217228

218229
/**
219230
* Removes a message reference from the element using aria-describedby
220231
* and decrements the registered message's reference count.
221232
*/
222-
private _removeMessageReference(element: Element, message: string|HTMLElement) {
223-
const registeredMessage = messageRegistry.get(message)!;
233+
private _removeMessageReference(element: Element, key: string|Element) {
234+
const registeredMessage = messageRegistry.get(key)!;
224235
registeredMessage.referenceCount--;
225236

226237
removeAriaReferencedId(element, 'aria-describedby', registeredMessage.messageElement.id);
227238
element.removeAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE);
228239
}
229240

230241
/** Returns true if the element has been described by the provided message ID. */
231-
private _isElementDescribedByMessage(element: Element, message: string|HTMLElement): boolean {
242+
private _isElementDescribedByMessage(element: Element, key: string|Element): boolean {
232243
const referenceIds = getAriaReferenceIds(element, 'aria-describedby');
233-
const registeredMessage = messageRegistry.get(message);
244+
const registeredMessage = messageRegistry.get(key);
234245
const messageId = registeredMessage && registeredMessage.messageElement.id;
235246

236247
return !!messageId && referenceIds.indexOf(messageId) != -1;
@@ -262,3 +273,15 @@ export class AriaDescriber implements OnDestroy {
262273
return element.nodeType === this._document.ELEMENT_NODE;
263274
}
264275
}
276+
277+
/** Gets a key that can be used to look messages up in the registry. */
278+
function getKey(message: string|Element, role?: string): string|Element {
279+
return typeof message === 'string' ? `${role || ''}/${message}` : message;
280+
}
281+
282+
/** Assigns a unique ID to an element, if it doesn't have one already. */
283+
function setMessageId(element: HTMLElement) {
284+
if (!element.id) {
285+
element.id = `${CDK_DESCRIBEDBY_ID_PREFIX}-${nextId++}`;
286+
}
287+
}

tools/public_api_guard/cdk/a11y.d.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ export declare class ActiveDescendantKeyManager<T> extends ListKeyManager<Highli
1212
export declare class AriaDescriber implements OnDestroy {
1313
constructor(_document: any,
1414
_platform?: Platform | undefined);
15-
describe(hostElement: Element, message: string | HTMLElement): void;
15+
describe(hostElement: Element, message: string, role?: string): void;
16+
describe(hostElement: Element, message: HTMLElement): void;
1617
ngOnDestroy(): void;
17-
removeDescription(hostElement: Element, message: string | HTMLElement): void;
18+
removeDescription(hostElement: Element, message: string, role?: string): void;
19+
removeDescription(hostElement: Element, message: HTMLElement): void;
1820
static ɵfac: i0.ɵɵFactoryDef<AriaDescriber, never>;
1921
static ɵprov: i0.ɵɵInjectableDef<AriaDescriber>;
2022
}

0 commit comments

Comments
 (0)