Skip to content

Commit

Permalink
fix(cdk/a11y): allow for multiple browser-generated description conta…
Browse files Browse the repository at this point in the history
…iners (#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)
  • Loading branch information
crisbeto authored and andrewseguin committed Jan 31, 2022
1 parent c02c43a commit 70d1634
Show file tree
Hide file tree
Showing 3 changed files with 102 additions and 62 deletions.
24 changes: 18 additions & 6 deletions src/cdk/a11y/aria-describer/aria-describer.spec.ts
@@ -1,5 +1,5 @@
import {A11yModule, CDK_DESCRIBEDBY_HOST_ATTRIBUTE} from '../index';
import {AriaDescriber, MESSAGES_CONTAINER_ID} from './aria-describer';
import {AriaDescriber} from './aria-describer';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {Component, ElementRef, ViewChild, Provider} from '@angular/core';

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

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

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

// Use `querySelectorAll` with an attribute since `getElementById` will stop at the first match.
expect(document.querySelectorAll(`[id='${MESSAGES_CONTAINER_ID}']`).length).toBe(1);
expect(document.querySelectorAll('.cdk-describedby-message-container').length).toBe(1);
extraContainer.remove();
});

it('should not clear any pre-existing containers coming from the browser', () => {
createFixture();
const extraContainer = document.createElement('div');
extraContainer.classList.add('cdk-describedby-message-container');
document.body.appendChild(extraContainer);

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

expect(document.querySelectorAll('.cdk-describedby-message-container').length).toBe(2);
extraContainer.remove();
});

Expand Down Expand Up @@ -337,7 +349,7 @@ describe('AriaDescriber', () => {
});

function getMessagesContainer() {
return document.querySelector(`#${MESSAGES_CONTAINER_ID}`)!;
return document.querySelector('.cdk-describedby-message-container')!;
}

function getMessageElements(): Element[] | null {
Expand Down
131 changes: 79 additions & 52 deletions src/cdk/a11y/aria-describer/aria-describer.ts
Expand Up @@ -8,6 +8,7 @@

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

/**
Expand All @@ -22,24 +23,30 @@ export interface RegisteredMessage {
referenceCount: number;
}

/** ID used for the body container where all messages are appended. */
/**
* ID used for the body container where all messages are appended.
* @deprecated No longer being used. To be removed.
* @breaking-change 14.0.0
*/
export const MESSAGES_CONTAINER_ID = 'cdk-describedby-message-container';

/** ID prefix used for each created message element. */
/**
* ID prefix used for each created message element.
* @deprecated To be turned into a private variable.
* @breaking-change 14.0.0
*/
export const CDK_DESCRIBEDBY_ID_PREFIX = 'cdk-describedby-message';

/** Attribute given to each host element that is described by a message element. */
/**
* Attribute given to each host element that is described by a message element.
* @deprecated To be turned into a private variable.
* @breaking-change 14.0.0
*/
export const CDK_DESCRIBEDBY_HOST_ATTRIBUTE = 'cdk-describedby-host';

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

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

/** Container for all registered messages. */
let messagesContainer: HTMLElement | null = null;

/**
* Utility that creates visually hidden elements with a message content. Useful for elements that
* want to use aria-describedby to further describe themselves without adding additional visual
Expand All @@ -49,7 +56,23 @@ let messagesContainer: HTMLElement | null = null;
export class AriaDescriber implements OnDestroy {
private _document: Document;

constructor(@Inject(DOCUMENT) _document: any) {
/** Map of all registered message elements that have been placed into the document. */
private _messageRegistry = new Map<string | Element, RegisteredMessage>();

/** Container for all registered messages. */
private _messagesContainer: HTMLElement | null = null;

/** Unique ID for the service. */
private readonly _id = `${nextId++}`;

constructor(
@Inject(DOCUMENT) _document: any,
/**
* @deprecated To be turned into a required parameter.
* @breaking-change 14.0.0
*/
private _platform?: Platform,
) {
this._document = _document;
}

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

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

if (messagesContainer && messagesContainer.childNodes.length === 0) {
this._deleteMessagesContainer();
if (this._messagesContainer?.childNodes.length === 0) {
this._messagesContainer.remove();
this._messagesContainer = null;
}
}

/** Unregisters all created message elements and removes the message container. */
ngOnDestroy() {
const describedElements = this._document.querySelectorAll(
`[${CDK_DESCRIBEDBY_HOST_ATTRIBUTE}]`,
`[${CDK_DESCRIBEDBY_HOST_ATTRIBUTE}="${this._id}"]`,
);

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

if (messagesContainer) {
this._deleteMessagesContainer();
}

messageRegistry.clear();
this._messagesContainer?.remove();
this._messagesContainer = null;
this._messageRegistry.clear();
}

/**
Expand All @@ -148,49 +170,54 @@ export class AriaDescriber implements OnDestroy {
}

this._createMessagesContainer();
messagesContainer!.appendChild(messageElement);
messageRegistry.set(getKey(message, role), {messageElement, referenceCount: 0});
this._messagesContainer!.appendChild(messageElement);
this._messageRegistry.set(getKey(message, role), {messageElement, referenceCount: 0});
}

/** Deletes the message element from the global messages container. */
private _deleteMessageElement(key: string | Element) {
const registeredMessage = messageRegistry.get(key);
registeredMessage?.messageElement?.remove();
messageRegistry.delete(key);
this._messageRegistry.get(key)?.messageElement?.remove();
this._messageRegistry.delete(key);
}

/** Creates the global container for all aria-describedby messages. */
private _createMessagesContainer() {
if (!messagesContainer) {
const preExistingContainer = this._document.getElementById(MESSAGES_CONTAINER_ID);
if (this._messagesContainer) {
return;
}

const containerClassName = 'cdk-describedby-message-container';
const serverContainers = this._document.querySelectorAll(
`.${containerClassName}[platform="server"]`,
);

for (let i = 0; i < serverContainers.length; i++) {
// When going from the server to the client, we may end up in a situation where there's
// already a container on the page, but we don't have a reference to it. Clear the
// old container so we don't get duplicates. Doing this, instead of emptying the previous
// container, should be slightly faster.
preExistingContainer?.remove();

messagesContainer = this._document.createElement('div');
messagesContainer.id = MESSAGES_CONTAINER_ID;
// We add `visibility: hidden` in order to prevent text in this container from
// being searchable by the browser's Ctrl + F functionality.
// Screen-readers will still read the description for elements with aria-describedby even
// when the description element is not visible.
messagesContainer.style.visibility = 'hidden';
// Even though we use `visibility: hidden`, we still apply `cdk-visually-hidden` so that
// the description element doesn't impact page layout.
messagesContainer.classList.add('cdk-visually-hidden');

this._document.body.appendChild(messagesContainer);
serverContainers[i].remove();
}
}

/** Deletes the global messages container. */
private _deleteMessagesContainer() {
if (messagesContainer) {
messagesContainer.remove();
messagesContainer = null;
const messagesContainer = this._document.createElement('div');

// We add `visibility: hidden` in order to prevent text in this container from
// being searchable by the browser's Ctrl + F functionality.
// Screen-readers will still read the description for elements with aria-describedby even
// when the description element is not visible.
messagesContainer.style.visibility = 'hidden';
// Even though we use `visibility: hidden`, we still apply `cdk-visually-hidden` so that
// the description element doesn't impact page layout.
messagesContainer.classList.add(containerClassName);
messagesContainer.classList.add('cdk-visually-hidden');

// @breaking-change 14.0.0 Remove null check for `_platform`.
if (this._platform && !this._platform.isBrowser) {
messagesContainer.setAttribute('platform', 'server');
}

this._document.body.appendChild(messagesContainer);
this._messagesContainer = messagesContainer;
}

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

// Add the aria-describedby reference and set the
// describedby_host attribute to mark the element.
addAriaReferencedId(element, 'aria-describedby', registeredMessage.messageElement.id);
element.setAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE, '');
element.setAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE, this._id);
registeredMessage.referenceCount++;
}

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

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

return !!messageId && referenceIds.indexOf(messageId) != -1;
Expand Down
9 changes: 5 additions & 4 deletions tools/public_api_guard/cdk/a11y.md
Expand Up @@ -43,7 +43,8 @@ export class ActiveDescendantKeyManager<T> extends ListKeyManager<Highlightable

// @public
export class AriaDescriber implements OnDestroy {
constructor(_document: any);
constructor(_document: any,
_platform?: Platform | undefined);
describe(hostElement: Element, message: string, role?: string): void;
describe(hostElement: Element, message: HTMLElement): void;
ngOnDestroy(): void;
Expand All @@ -58,10 +59,10 @@ export class AriaDescriber implements OnDestroy {
// @public
export type AriaLivePoliteness = 'off' | 'polite' | 'assertive';

// @public
// @public @deprecated
export const CDK_DESCRIBEDBY_HOST_ATTRIBUTE = "cdk-describedby-host";

// @public
// @public @deprecated
export const CDK_DESCRIBEDBY_ID_PREFIX = "cdk-describedby-message";

// @public
Expand Down Expand Up @@ -398,7 +399,7 @@ export interface LiveAnnouncerDefaultOptions {
politeness?: AriaLivePoliteness;
}

// @public
// @public @deprecated
export const MESSAGES_CONTAINER_ID = "cdk-describedby-message-container";

// @public
Expand Down

0 comments on commit 70d1634

Please sign in to comment.