Skip to content

Commit 70d1634

Browse files
crisbetoandrewseguin
authored andcommitted
fix(cdk/a11y): allow for multiple browser-generated description containers (#23507)
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. (cherry picked from commit 781a45a)
1 parent c02c43a commit 70d1634

File tree

3 files changed

+102
-62
lines changed

3 files changed

+102
-62
lines changed

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

+18-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

@@ -209,16 +209,28 @@ describe('AriaDescriber', () => {
209209
expect(() => ariaDescriber.describe(node, 'This looks like an element')).not.toThrow();
210210
});
211211

212-
it('should clear any pre-existing containers', () => {
212+
it('should clear any pre-existing containers coming in from the server', () => {
213213
createFixture();
214214
const extraContainer = document.createElement('div');
215-
extraContainer.id = MESSAGES_CONTAINER_ID;
215+
extraContainer.classList.add('cdk-describedby-message-container');
216+
extraContainer.setAttribute('platform', 'server');
216217
document.body.appendChild(extraContainer);
217218

218219
ariaDescriber.describe(component.element1, 'Hello');
219220

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

@@ -337,7 +349,7 @@ describe('AriaDescriber', () => {
337349
});
338350

339351
function getMessagesContainer() {
340-
return document.querySelector(`#${MESSAGES_CONTAINER_ID}`)!;
352+
return document.querySelector('.cdk-describedby-message-container')!;
341353
}
342354

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

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

+79-52
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
/**
@@ -22,24 +23,30 @@ export interface RegisteredMessage {
2223
referenceCount: number;
2324
}
2425

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

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

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

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

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

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

@@ -75,8 +98,8 @@ export class AriaDescriber implements OnDestroy {
7598
if (typeof message !== 'string') {
7699
// We need to ensure that the element has an ID.
77100
setMessageId(message);
78-
messageRegistry.set(key, {messageElement: message, referenceCount: 0});
79-
} else if (!messageRegistry.has(key)) {
101+
this._messageRegistry.set(key, {messageElement: message, referenceCount: 0});
102+
} else if (!this._messageRegistry.has(key)) {
80103
this._createMessageElement(message, role);
81104
}
82105

@@ -105,33 +128,32 @@ export class AriaDescriber implements OnDestroy {
105128
// If the message is a string, it means that it's one that we created for the
106129
// consumer so we can remove it safely, otherwise we should leave it in place.
107130
if (typeof message === 'string') {
108-
const registeredMessage = messageRegistry.get(key);
131+
const registeredMessage = this._messageRegistry.get(key);
109132
if (registeredMessage && registeredMessage.referenceCount === 0) {
110133
this._deleteMessageElement(key);
111134
}
112135
}
113136

114-
if (messagesContainer && messagesContainer.childNodes.length === 0) {
115-
this._deleteMessagesContainer();
137+
if (this._messagesContainer?.childNodes.length === 0) {
138+
this._messagesContainer.remove();
139+
this._messagesContainer = null;
116140
}
117141
}
118142

119143
/** Unregisters all created message elements and removes the message container. */
120144
ngOnDestroy() {
121145
const describedElements = this._document.querySelectorAll(
122-
`[${CDK_DESCRIBEDBY_HOST_ATTRIBUTE}]`,
146+
`[${CDK_DESCRIBEDBY_HOST_ATTRIBUTE}="${this._id}"]`,
123147
);
124148

125149
for (let i = 0; i < describedElements.length; i++) {
126150
this._removeCdkDescribedByReferenceIds(describedElements[i]);
127151
describedElements[i].removeAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE);
128152
}
129153

130-
if (messagesContainer) {
131-
this._deleteMessagesContainer();
132-
}
133-
134-
messageRegistry.clear();
154+
this._messagesContainer?.remove();
155+
this._messagesContainer = null;
156+
this._messageRegistry.clear();
135157
}
136158

137159
/**
@@ -148,49 +170,54 @@ export class AriaDescriber implements OnDestroy {
148170
}
149171

150172
this._createMessagesContainer();
151-
messagesContainer!.appendChild(messageElement);
152-
messageRegistry.set(getKey(message, role), {messageElement, referenceCount: 0});
173+
this._messagesContainer!.appendChild(messageElement);
174+
this._messageRegistry.set(getKey(message, role), {messageElement, referenceCount: 0});
153175
}
154176

155177
/** Deletes the message element from the global messages container. */
156178
private _deleteMessageElement(key: string | Element) {
157-
const registeredMessage = messageRegistry.get(key);
158-
registeredMessage?.messageElement?.remove();
159-
messageRegistry.delete(key);
179+
this._messageRegistry.get(key)?.messageElement?.remove();
180+
this._messageRegistry.delete(key);
160181
}
161182

162183
/** Creates the global container for all aria-describedby messages. */
163184
private _createMessagesContainer() {
164-
if (!messagesContainer) {
165-
const preExistingContainer = this._document.getElementById(MESSAGES_CONTAINER_ID);
185+
if (this._messagesContainer) {
186+
return;
187+
}
166188

189+
const containerClassName = 'cdk-describedby-message-container';
190+
const serverContainers = this._document.querySelectorAll(
191+
`.${containerClassName}[platform="server"]`,
192+
);
193+
194+
for (let i = 0; i < serverContainers.length; i++) {
167195
// When going from the server to the client, we may end up in a situation where there's
168196
// already a container on the page, but we don't have a reference to it. Clear the
169197
// old container so we don't get duplicates. Doing this, instead of emptying the previous
170198
// container, should be slightly faster.
171-
preExistingContainer?.remove();
172-
173-
messagesContainer = this._document.createElement('div');
174-
messagesContainer.id = MESSAGES_CONTAINER_ID;
175-
// We add `visibility: hidden` in order to prevent text in this container from
176-
// being searchable by the browser's Ctrl + F functionality.
177-
// Screen-readers will still read the description for elements with aria-describedby even
178-
// when the description element is not visible.
179-
messagesContainer.style.visibility = 'hidden';
180-
// Even though we use `visibility: hidden`, we still apply `cdk-visually-hidden` so that
181-
// the description element doesn't impact page layout.
182-
messagesContainer.classList.add('cdk-visually-hidden');
183-
184-
this._document.body.appendChild(messagesContainer);
199+
serverContainers[i].remove();
185200
}
186-
}
187201

188-
/** Deletes the global messages container. */
189-
private _deleteMessagesContainer() {
190-
if (messagesContainer) {
191-
messagesContainer.remove();
192-
messagesContainer = null;
202+
const messagesContainer = this._document.createElement('div');
203+
204+
// We add `visibility: hidden` in order to prevent text in this container from
205+
// being searchable by the browser's Ctrl + F functionality.
206+
// Screen-readers will still read the description for elements with aria-describedby even
207+
// when the description element is not visible.
208+
messagesContainer.style.visibility = 'hidden';
209+
// Even though we use `visibility: hidden`, we still apply `cdk-visually-hidden` so that
210+
// the description element doesn't impact page layout.
211+
messagesContainer.classList.add(containerClassName);
212+
messagesContainer.classList.add('cdk-visually-hidden');
213+
214+
// @breaking-change 14.0.0 Remove null check for `_platform`.
215+
if (this._platform && !this._platform.isBrowser) {
216+
messagesContainer.setAttribute('platform', 'server');
193217
}
218+
219+
this._document.body.appendChild(messagesContainer);
220+
this._messagesContainer = messagesContainer;
194221
}
195222

196223
/** Removes all cdk-describedby messages that are hosted through the element. */
@@ -207,12 +234,12 @@ export class AriaDescriber implements OnDestroy {
207234
* message's reference count.
208235
*/
209236
private _addMessageReference(element: Element, key: string | Element) {
210-
const registeredMessage = messageRegistry.get(key)!;
237+
const registeredMessage = this._messageRegistry.get(key)!;
211238

212239
// Add the aria-describedby reference and set the
213240
// describedby_host attribute to mark the element.
214241
addAriaReferencedId(element, 'aria-describedby', registeredMessage.messageElement.id);
215-
element.setAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE, '');
242+
element.setAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE, this._id);
216243
registeredMessage.referenceCount++;
217244
}
218245

@@ -221,7 +248,7 @@ export class AriaDescriber implements OnDestroy {
221248
* and decrements the registered message's reference count.
222249
*/
223250
private _removeMessageReference(element: Element, key: string | Element) {
224-
const registeredMessage = messageRegistry.get(key)!;
251+
const registeredMessage = this._messageRegistry.get(key)!;
225252
registeredMessage.referenceCount--;
226253

227254
removeAriaReferencedId(element, 'aria-describedby', registeredMessage.messageElement.id);
@@ -231,7 +258,7 @@ export class AriaDescriber implements OnDestroy {
231258
/** Returns true if the element has been described by the provided message ID. */
232259
private _isElementDescribedByMessage(element: Element, key: string | Element): boolean {
233260
const referenceIds = getAriaReferenceIds(element, 'aria-describedby');
234-
const registeredMessage = messageRegistry.get(key);
261+
const registeredMessage = this._messageRegistry.get(key);
235262
const messageId = registeredMessage && registeredMessage.messageElement.id;
236263

237264
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
@@ -398,7 +399,7 @@ export interface LiveAnnouncerDefaultOptions {
398399
politeness?: AriaLivePoliteness;
399400
}
400401

401-
// @public
402+
// @public @deprecated
402403
export const MESSAGES_CONTAINER_ID = "cdk-describedby-message-container";
403404

404405
// @public

0 commit comments

Comments
 (0)