Skip to content

Commit 937446c

Browse files
committed
fix(cdk/a11y): allow for multiple browser-generated description containers
Currently the `AriaDescriber` is set up to clear all description containers whenever it is instantiated, in order to avoid duplicates coming in from the server. The problem is that there are legitimate use cases where we could have multiple containers (e.g. multiple CDK instances in a micro frontend architecture). These changes rework the internal setup of the `AriaDescriber` so that it only clears containers from server and it doesn't touch containers coming from other describer instances. Fixes #23499.
1 parent 5998dd3 commit 937446c

File tree

3 files changed

+106
-59
lines changed

3 files changed

+106
-59
lines changed

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

+21-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {A11yModule, CDK_DESCRIBEDBY_HOST_ATTRIBUTE} from '../index';
2-
import {AriaDescriber, MESSAGES_CONTAINER_ID} from './aria-describer';
2+
import {AriaDescriber} from './aria-describer';
33
import {ComponentFixture, TestBed} from '@angular/core/testing';
44
import {Component, ElementRef, ViewChild, Provider} from '@angular/core';
55

@@ -204,16 +204,31 @@ describe('AriaDescriber', () => {
204204
expect(() => ariaDescriber.describe(node, 'This looks like an element')).not.toThrow();
205205
});
206206

207-
it('should clear any pre-existing containers', () => {
207+
it('should clear any pre-existing containers coming in from the server', () => {
208208
createFixture();
209209
const extraContainer = document.createElement('div');
210-
extraContainer.id = MESSAGES_CONTAINER_ID;
210+
extraContainer.classList.add('cdk-describedby-message-container');
211+
extraContainer.setAttribute('platform', 'server');
211212
document.body.appendChild(extraContainer);
212213

213214
ariaDescriber.describe(component.element1, 'Hello');
214215

215-
// Use `querySelectorAll` with an attribute since `getElementById` will stop at the first match.
216-
expect(document.querySelectorAll(`[id='${MESSAGES_CONTAINER_ID}']`).length).toBe(1);
216+
expect(document.querySelectorAll('.cdk-describedby-message-container').length).toBe(1);
217+
218+
if (extraContainer.parentNode) {
219+
extraContainer.parentNode.removeChild(extraContainer);
220+
}
221+
});
222+
223+
it('should not clear any pre-existing containers coming from the browser', () => {
224+
createFixture();
225+
const extraContainer = document.createElement('div');
226+
extraContainer.classList.add('cdk-describedby-message-container');
227+
document.body.appendChild(extraContainer);
228+
229+
ariaDescriber.describe(component.element1, 'Hello');
230+
231+
expect(document.querySelectorAll('.cdk-describedby-message-container').length).toBe(2);
217232

218233
if (extraContainer.parentNode) {
219234
extraContainer.parentNode.removeChild(extraContainer);
@@ -334,7 +349,7 @@ describe('AriaDescriber', () => {
334349
});
335350

336351
function getMessagesContainer() {
337-
return document.querySelector(`#${MESSAGES_CONTAINER_ID}`)!;
352+
return document.querySelector('.cdk-describedby-message-container')!;
338353
}
339354

340355
function getMessageElements(): Element[] | null {

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

+80-49
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import {DOCUMENT} from '@angular/common';
1010
import {Inject, Injectable, OnDestroy} from '@angular/core';
11+
import {Platform} from '@angular/cdk/platform';
1112
import {addAriaReferencedId, getAriaReferenceIds, removeAriaReferencedId} from './aria-reference';
1213

1314

@@ -23,24 +24,30 @@ export interface RegisteredMessage {
2324
referenceCount: number;
2425
}
2526

26-
/** ID used for the body container where all messages are appended. */
27+
/**
28+
* ID used for the body container where all messages are appended.
29+
* @deprecated No longer being used. To be removed.
30+
* @breaking-change 14.0.0
31+
*/
2732
export const MESSAGES_CONTAINER_ID = 'cdk-describedby-message-container';
2833

29-
/** ID prefix used for each created message element. */
34+
/**
35+
* ID prefix used for each created message element.
36+
* @deprecated To be turned into a private variable.
37+
* @breaking-change 14.0.0
38+
*/
3039
export const CDK_DESCRIBEDBY_ID_PREFIX = 'cdk-describedby-message';
3140

32-
/** Attribute given to each host element that is described by a message element. */
41+
/**
42+
* Attribute given to each host element that is described by a message element.
43+
* @deprecated To be turned into a private variable.
44+
* @breaking-change 14.0.0
45+
*/
3346
export const CDK_DESCRIBEDBY_HOST_ATTRIBUTE = 'cdk-describedby-host';
3447

3548
/** Global incremental identifier for each registered message element. */
3649
let nextId = 0;
3750

38-
/** Global map of all registered message elements that have been placed into the document. */
39-
const messageRegistry = new Map<string|Element, RegisteredMessage>();
40-
41-
/** Container for all registered messages. */
42-
let messagesContainer: HTMLElement | null = null;
43-
4451
/**
4552
* Utility that creates visually hidden elements with a message content. Useful for elements that
4653
* want to use aria-describedby to further describe themselves without adding additional visual
@@ -50,8 +57,22 @@ let messagesContainer: HTMLElement | null = null;
5057
export class AriaDescriber implements OnDestroy {
5158
private _document: Document;
5259

60+
/** Map of all registered message elements that have been placed into the document. */
61+
private _messageRegistry = new Map<string|Element, RegisteredMessage>();
62+
63+
/** Container for all registered messages. */
64+
private _messagesContainer: HTMLElement | null = null;
65+
66+
/** Unique ID for the service. */
67+
private readonly _id = `${nextId++}`;
68+
5369
constructor(
54-
@Inject(DOCUMENT) _document: any) {
70+
@Inject(DOCUMENT) _document: any,
71+
/**
72+
* @deprecated To be turned into a required parameter.
73+
* @breaking-change 14.0.0
74+
*/
75+
private _platform?: Platform) {
5576
this._document = _document;
5677
}
5778

@@ -77,8 +98,8 @@ export class AriaDescriber implements OnDestroy {
7798
if (typeof message !== 'string') {
7899
// We need to ensure that the element has an ID.
79100
setMessageId(message);
80-
messageRegistry.set(key, {messageElement: message, referenceCount: 0});
81-
} else if (!messageRegistry.has(key)) {
101+
this._messageRegistry.set(key, {messageElement: message, referenceCount: 0});
102+
} else if (!this._messageRegistry.has(key)) {
82103
this._createMessageElement(message, role);
83104
}
84105

@@ -107,32 +128,29 @@ export class AriaDescriber implements OnDestroy {
107128
// If the message is a string, it means that it's one that we created for the
108129
// consumer so we can remove it safely, otherwise we should leave it in place.
109130
if (typeof message === 'string') {
110-
const registeredMessage = messageRegistry.get(key);
131+
const registeredMessage = this._messageRegistry.get(key);
111132
if (registeredMessage && registeredMessage.referenceCount === 0) {
112133
this._deleteMessageElement(key);
113134
}
114135
}
115136

116-
if (messagesContainer && messagesContainer.childNodes.length === 0) {
137+
if (this._messagesContainer?.childNodes.length === 0) {
117138
this._deleteMessagesContainer();
118139
}
119140
}
120141

121142
/** Unregisters all created message elements and removes the message container. */
122143
ngOnDestroy() {
123144
const describedElements =
124-
this._document.querySelectorAll(`[${CDK_DESCRIBEDBY_HOST_ATTRIBUTE}]`);
145+
this._document.querySelectorAll(`[${CDK_DESCRIBEDBY_HOST_ATTRIBUTE}="${this._id}"]`);
125146

126147
for (let i = 0; i < describedElements.length; i++) {
127148
this._removeCdkDescribedByReferenceIds(describedElements[i]);
128149
describedElements[i].removeAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE);
129150
}
130151

131-
if (messagesContainer) {
132-
this._deleteMessagesContainer();
133-
}
134-
135-
messageRegistry.clear();
152+
this._deleteMessagesContainer();
153+
this._messageRegistry.clear();
136154
}
137155

138156
/**
@@ -149,53 +167,66 @@ export class AriaDescriber implements OnDestroy {
149167
}
150168

151169
this._createMessagesContainer();
152-
messagesContainer!.appendChild(messageElement);
153-
messageRegistry.set(getKey(message, role), {messageElement, referenceCount: 0});
170+
this._messagesContainer!.appendChild(messageElement);
171+
this._messageRegistry.set(getKey(message, role), {messageElement, referenceCount: 0});
154172
}
155173

156174
/** Deletes the message element from the global messages container. */
157175
private _deleteMessageElement(key: string|Element) {
158-
const registeredMessage = messageRegistry.get(key);
176+
const registeredMessage = this._messageRegistry.get(key);
159177
const messageElement = registeredMessage && registeredMessage.messageElement;
160-
if (messagesContainer && messageElement) {
161-
messagesContainer.removeChild(messageElement);
178+
if (this._messagesContainer && messageElement) {
179+
this._messagesContainer.removeChild(messageElement);
162180
}
163-
messageRegistry.delete(key);
181+
this._messageRegistry.delete(key);
164182
}
165183

166184
/** Creates the global container for all aria-describedby messages. */
167185
private _createMessagesContainer() {
168-
if (!messagesContainer) {
169-
const preExistingContainer = this._document.getElementById(MESSAGES_CONTAINER_ID);
186+
if (this._messagesContainer) {
187+
return;
188+
}
170189

190+
const containerClassName = 'cdk-describedby-message-container';
191+
const serverContainers =
192+
this._document.querySelectorAll(`.${containerClassName}[platform="server"]`);
193+
194+
for (let i = 0; i < serverContainers.length; i++) {
171195
// When going from the server to the client, we may end up in a situation where there's
172196
// already a container on the page, but we don't have a reference to it. Clear the
173197
// old container so we don't get duplicates. Doing this, instead of emptying the previous
174198
// container, should be slightly faster.
175-
if (preExistingContainer && preExistingContainer.parentNode) {
176-
preExistingContainer.parentNode.removeChild(preExistingContainer);
199+
const serverContainer = serverContainers[i];
200+
if (serverContainer.parentNode) {
201+
serverContainer.parentNode.removeChild(serverContainer);
177202
}
203+
}
204+
205+
const messagesContainer = this._document.createElement('div');
178206

179-
messagesContainer = this._document.createElement('div');
180-
messagesContainer.id = MESSAGES_CONTAINER_ID;
181-
// We add `visibility: hidden` in order to prevent text in this container from
182-
// being searchable by the browser's Ctrl + F functionality.
183-
// Screen-readers will still read the description for elements with aria-describedby even
184-
// when the description element is not visible.
185-
messagesContainer.style.visibility = 'hidden';
186-
// Even though we use `visibility: hidden`, we still apply `cdk-visually-hidden` so that
187-
// the description element doesn't impact page layout.
188-
messagesContainer.classList.add('cdk-visually-hidden');
189-
190-
this._document.body.appendChild(messagesContainer);
207+
// We add `visibility: hidden` in order to prevent text in this container from
208+
// being searchable by the browser's Ctrl + F functionality.
209+
// Screen-readers will still read the description for elements with aria-describedby even
210+
// when the description element is not visible.
211+
messagesContainer.style.visibility = 'hidden';
212+
// Even though we use `visibility: hidden`, we still apply `cdk-visually-hidden` so that
213+
// the description element doesn't impact page layout.
214+
messagesContainer.classList.add(containerClassName, 'cdk-visually-hidden');
215+
216+
// @breaking-change 14.0.0 Remove null check for `_platform`.
217+
if (this._platform && !this._platform.isBrowser) {
218+
messagesContainer.setAttribute('platform', 'server');
191219
}
220+
221+
this._document.body.appendChild(messagesContainer);
222+
this._messagesContainer = messagesContainer;
192223
}
193224

194225
/** Deletes the global messages container. */
195226
private _deleteMessagesContainer() {
196-
if (messagesContainer && messagesContainer.parentNode) {
197-
messagesContainer.parentNode.removeChild(messagesContainer);
198-
messagesContainer = null;
227+
if (this._messagesContainer && this._messagesContainer.parentNode) {
228+
this._messagesContainer.parentNode.removeChild(this._messagesContainer);
229+
this._messagesContainer = null;
199230
}
200231
}
201232

@@ -212,12 +243,12 @@ export class AriaDescriber implements OnDestroy {
212243
* message's reference count.
213244
*/
214245
private _addMessageReference(element: Element, key: string|Element) {
215-
const registeredMessage = messageRegistry.get(key)!;
246+
const registeredMessage = this._messageRegistry.get(key)!;
216247

217248
// Add the aria-describedby reference and set the
218249
// describedby_host attribute to mark the element.
219250
addAriaReferencedId(element, 'aria-describedby', registeredMessage.messageElement.id);
220-
element.setAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE, '');
251+
element.setAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE, this._id);
221252
registeredMessage.referenceCount++;
222253
}
223254

@@ -226,7 +257,7 @@ export class AriaDescriber implements OnDestroy {
226257
* and decrements the registered message's reference count.
227258
*/
228259
private _removeMessageReference(element: Element, key: string|Element) {
229-
const registeredMessage = messageRegistry.get(key)!;
260+
const registeredMessage = this._messageRegistry.get(key)!;
230261
registeredMessage.referenceCount--;
231262

232263
removeAriaReferencedId(element, 'aria-describedby', registeredMessage.messageElement.id);
@@ -236,7 +267,7 @@ export class AriaDescriber implements OnDestroy {
236267
/** Returns true if the element has been described by the provided message ID. */
237268
private _isElementDescribedByMessage(element: Element, key: string|Element): boolean {
238269
const referenceIds = getAriaReferenceIds(element, 'aria-describedby');
239-
const registeredMessage = messageRegistry.get(key);
270+
const registeredMessage = this._messageRegistry.get(key);
240271
const messageId = registeredMessage && registeredMessage.messageElement.id;
241272

242273
return !!messageId && referenceIds.indexOf(messageId) != -1;

tools/public_api_guard/cdk/a11y.md

+5-4
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ export class ActiveDescendantKeyManager<T> extends ListKeyManager<Highlightable
4343

4444
// @public
4545
export class AriaDescriber implements OnDestroy {
46-
constructor(_document: any);
46+
constructor(_document: any,
47+
_platform?: Platform | undefined);
4748
describe(hostElement: Element, message: string, role?: string): void;
4849
describe(hostElement: Element, message: HTMLElement): void;
4950
ngOnDestroy(): void;
@@ -58,10 +59,10 @@ export class AriaDescriber implements OnDestroy {
5859
// @public
5960
export type AriaLivePoliteness = 'off' | 'polite' | 'assertive';
6061

61-
// @public
62+
// @public @deprecated
6263
export const CDK_DESCRIBEDBY_HOST_ATTRIBUTE = "cdk-describedby-host";
6364

64-
// @public
65+
// @public @deprecated
6566
export const CDK_DESCRIBEDBY_ID_PREFIX = "cdk-describedby-message";
6667

6768
// @public
@@ -402,7 +403,7 @@ export interface LiveAnnouncerDefaultOptions {
402403
politeness?: AriaLivePoliteness;
403404
}
404405

405-
// @public
406+
// @public @deprecated
406407
export const MESSAGES_CONTAINER_ID = "cdk-describedby-message-container";
407408

408409
// @public

0 commit comments

Comments
 (0)