diff --git a/src/cdk/a11y/aria-describer/aria-describer.spec.ts b/src/cdk/a11y/aria-describer/aria-describer.spec.ts index 01ffe74caeed..28f70523ba6a 100644 --- a/src/cdk/a11y/aria-describer/aria-describer.spec.ts +++ b/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'; @@ -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(); }); @@ -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 { diff --git a/src/cdk/a11y/aria-describer/aria-describer.ts b/src/cdk/a11y/aria-describer/aria-describer.ts index 171b3ca83b3f..c7129e135306 100644 --- a/src/cdk/a11y/aria-describer/aria-describer.ts +++ b/src/cdk/a11y/aria-describer/aria-describer.ts @@ -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'; /** @@ -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(); - -/** 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 @@ -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(); + + /** 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; } @@ -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); } @@ -105,21 +128,22 @@ 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++) { @@ -127,11 +151,9 @@ export class AriaDescriber implements OnDestroy { describedElements[i].removeAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE); } - if (messagesContainer) { - this._deleteMessagesContainer(); - } - - messageRegistry.clear(); + this._messagesContainer?.remove(); + this._messagesContainer = null; + this._messageRegistry.clear(); } /** @@ -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. */ @@ -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++; } @@ -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); @@ -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; diff --git a/tools/public_api_guard/cdk/a11y.md b/tools/public_api_guard/cdk/a11y.md index 39dddbe4303c..1018a7477689 100644 --- a/tools/public_api_guard/cdk/a11y.md +++ b/tools/public_api_guard/cdk/a11y.md @@ -43,7 +43,8 @@ export class ActiveDescendantKeyManager extends ListKeyManager