diff --git a/src/cdk/a11y/aria-describer/aria-describer.spec.ts b/src/cdk/a11y/aria-describer/aria-describer.spec.ts
index 66d4e08af3e..0b71a7e9901 100644
--- a/src/cdk/a11y/aria-describer/aria-describer.spec.ts
+++ b/src/cdk/a11y/aria-describer/aria-describer.spec.ts
@@ -36,6 +36,12 @@ describe('AriaDescriber', () => {
expectMessages(['My Message']);
});
+ it('should be able to describe using an element', () => {
+ const descriptionNode = fixture.nativeElement.querySelector('#description-with-existing-id');
+ ariaDescriber.describe(component.element1, descriptionNode);
+ expectMessage(component.element1, 'Hello');
+ });
+
it('should not register empty strings', () => {
ariaDescriber.describe(component.element1, '');
expect(getMessageElements()).toBe(null);
@@ -60,6 +66,16 @@ describe('AriaDescriber', () => {
expectMessage(component.element3, 'My Message');
});
+ it('should de-dupe a message registered multiple via an element node', () => {
+ const descriptionNode = fixture.nativeElement.querySelector('#description-with-existing-id');
+ ariaDescriber.describe(component.element1, descriptionNode);
+ ariaDescriber.describe(component.element2, descriptionNode);
+ ariaDescriber.describe(component.element3, descriptionNode);
+ expectMessage(component.element1, 'Hello');
+ expectMessage(component.element2, 'Hello');
+ expectMessage(component.element3, 'Hello');
+ });
+
it('should be able to register multiple messages', () => {
ariaDescriber.describe(component.element1, 'First Message');
ariaDescriber.describe(component.element2, 'Second Message');
@@ -87,6 +103,43 @@ describe('AriaDescriber', () => {
expect(getMessagesContainer()).toBeNull();
});
+ it('should not remove nodes that were set as messages when unregistering', () => {
+ const descriptionNode = fixture.nativeElement.querySelector('#description-with-existing-id');
+
+ expect(document.body.contains(descriptionNode))
+ .toBe(true, 'Expected node to be inside the document to begin with.');
+ expect(getMessagesContainer()).toBeNull('Expected no messages container on init.');
+
+ ariaDescriber.describe(component.element1, descriptionNode);
+
+ expectMessage(component.element1, 'Hello');
+ expect(getMessagesContainer())
+ .toBeNull('Expected no messages container after the element was described.');
+
+ ariaDescriber.removeDescription(component.element1, descriptionNode);
+
+ expect(document.body.contains(descriptionNode)).toBe(true,
+ 'Expected description node to still be in the DOM after it is no longer being used.');
+ });
+
+ it('should keep nodes set as descriptions inside their original position in the DOM', () => {
+ const descriptionNode = fixture.nativeElement.querySelector('#description-with-existing-id');
+ const initialParent = descriptionNode.parentNode;
+
+ expect(initialParent).toBeTruthy('Expected node to have a parent initially.');
+
+ ariaDescriber.describe(component.element1, descriptionNode);
+
+ expectMessage(component.element1, 'Hello');
+ expect(descriptionNode.parentNode).toBe(initialParent,
+ 'Expected node to stay inside the same parent when used as a description.');
+
+ ariaDescriber.removeDescription(component.element1, descriptionNode);
+
+ expect(descriptionNode.parentNode).toBe(initialParent,
+ 'Expected node to stay inside the same parent after not being used as a description.');
+ });
+
it('should be able to unregister messages while having others registered', () => {
ariaDescriber.describe(component.element1, 'Persistent Message');
ariaDescriber.describe(component.element2, 'My Message');
@@ -140,6 +193,39 @@ describe('AriaDescriber', () => {
ariaDescriber.describe(component.element1, 'Hi');
expectMessages(['Hi']);
});
+
+ it('should assign an id to the description element, if it does not have one', () => {
+ const descriptionNode = fixture.nativeElement.querySelector('[description-without-id]');
+ expect(descriptionNode.getAttribute('id')).toBeFalsy();
+ ariaDescriber.describe(component.element1, descriptionNode);
+ expect(descriptionNode.getAttribute('id')).toBeTruthy();
+ });
+
+ it('should not overwrite the existing id of the description element', () => {
+ const descriptionNode = fixture.nativeElement.querySelector('#description-with-existing-id');
+ expect(descriptionNode.id).toBe('description-with-existing-id');
+ ariaDescriber.describe(component.element1, descriptionNode);
+ expect(descriptionNode.id).toBe('description-with-existing-id');
+ });
+
+ it('should not remove pre-existing description nodes on destroy', () => {
+ const descriptionNode = fixture.nativeElement.querySelector('#description-with-existing-id');
+
+ expect(document.body.contains(descriptionNode))
+ .toBe(true, 'Expected node to be inside the document to begin with.');
+
+ ariaDescriber.describe(component.element1, descriptionNode);
+
+ expectMessage(component.element1, 'Hello');
+ expect(component.element1.hasAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE)).toBe(true);
+
+ ariaDescriber.ngOnDestroy();
+
+ expect(component.element1.hasAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE)).toBe(false);
+ expect(document.body.contains(descriptionNode)).toBe(true,
+ 'Expected description node to still be in the DOM after it is no longer being used.');
+ });
+
});
function getMessagesContainer() {
@@ -186,6 +272,8 @@ function expectMessage(el: Element, message: string) {
+ Hello
+ Hey
`,
})
class TestApp {
diff --git a/src/cdk/a11y/aria-describer/aria-describer.ts b/src/cdk/a11y/aria-describer/aria-describer.ts
index 10a6bbecc9f..4c088a80e9d 100644
--- a/src/cdk/a11y/aria-describer/aria-describer.ts
+++ b/src/cdk/a11y/aria-describer/aria-describer.ts
@@ -43,7 +43,7 @@ export const CDK_DESCRIBEDBY_HOST_ATTRIBUTE = 'cdk-describedby-host';
let nextId = 0;
/** Global map of all registered message elements that have been placed into the document. */
-const messageRegistry = new Map();
+const messageRegistry = new Map();
/** Container for all registered messages. */
let messagesContainer: HTMLElement | null = null;
@@ -52,7 +52,6 @@ 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
* content.
- * @docs-private
*/
@Injectable({providedIn: 'root'})
export class AriaDescriber implements OnDestroy {
@@ -67,12 +66,16 @@ export class AriaDescriber implements OnDestroy {
* the message. If the same message has already been registered, then it will reuse the created
* message element.
*/
- describe(hostElement: Element, message: string) {
+ describe(hostElement: Element, message: string|HTMLElement) {
if (!this._canBeDescribed(hostElement, message)) {
return;
}
- if (!messageRegistry.has(message)) {
+ if (typeof message !== 'string') {
+ // We need to ensure that the element has an ID.
+ this._setMessageId(message);
+ messageRegistry.set(message, {messageElement: message, referenceCount: 0});
+ } else if (!messageRegistry.has(message)) {
this._createMessageElement(message);
}
@@ -82,7 +85,7 @@ export class AriaDescriber implements OnDestroy {
}
/** Removes the host element's aria-describedby reference to the message element. */
- removeDescription(hostElement: Element, message: string) {
+ removeDescription(hostElement: Element, message: string|HTMLElement) {
if (!this._isElementNode(hostElement)) {
return;
}
@@ -91,9 +94,13 @@ export class AriaDescriber implements OnDestroy {
this._removeMessageReference(hostElement, message);
}
- const registeredMessage = messageRegistry.get(message);
- if (registeredMessage && registeredMessage.referenceCount === 0) {
- this._deleteMessageElement(message);
+ // 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(message);
+ if (registeredMessage && registeredMessage.referenceCount === 0) {
+ this._deleteMessageElement(message);
+ }
}
if (messagesContainer && messagesContainer.childNodes.length === 0) {
@@ -124,8 +131,8 @@ export class AriaDescriber implements OnDestroy {
*/
private _createMessageElement(message: string) {
const messageElement = this._document.createElement('div');
- messageElement.setAttribute('id', `${CDK_DESCRIBEDBY_ID_PREFIX}-${nextId++}`);
- messageElement.appendChild(this._document.createTextNode(message)!);
+ this._setMessageId(messageElement);
+ messageElement.textContent = message;
this._createMessagesContainer();
messagesContainer!.appendChild(messageElement);
@@ -133,6 +140,13 @@ export class AriaDescriber implements OnDestroy {
messageRegistry.set(message, {messageElement, referenceCount: 0});
}
+ /** Assigns a unique ID to an element, if it doesn't have one already. */
+ private _setMessageId(element: HTMLElement) {
+ if (!element.id) {
+ element.id = `${CDK_DESCRIBEDBY_ID_PREFIX}-${nextId++}`;
+ }
+ }
+
/** Deletes the message element from the global messages container. */
private _deleteMessageElement(message: string) {
const registeredMessage = messageRegistry.get(message);
@@ -184,7 +198,7 @@ export class AriaDescriber implements OnDestroy {
* Adds a message reference to the element using aria-describedby and increments the registered
* message's reference count.
*/
- private _addMessageReference(element: Element, message: string) {
+ private _addMessageReference(element: Element, message: string|HTMLElement) {
const registeredMessage = messageRegistry.get(message)!;
// Add the aria-describedby reference and set the
@@ -199,7 +213,7 @@ export class AriaDescriber implements OnDestroy {
* Removes a message reference from the element using aria-describedby
* and decrements the registered message's reference count.
*/
- private _removeMessageReference(element: Element, message: string) {
+ private _removeMessageReference(element: Element, message: string|HTMLElement) {
const registeredMessage = messageRegistry.get(message)!;
registeredMessage.referenceCount--;
@@ -208,7 +222,7 @@ export class AriaDescriber implements OnDestroy {
}
/** Returns true if the element has been described by the provided message ID. */
- private _isElementDescribedByMessage(element: Element, message: string): boolean {
+ private _isElementDescribedByMessage(element: Element, message: string|HTMLElement): boolean {
const referenceIds = getAriaReferenceIds(element, 'aria-describedby');
const registeredMessage = messageRegistry.get(message);
const messageId = registeredMessage && registeredMessage.messageElement.id;
@@ -217,16 +231,23 @@ export class AriaDescriber implements OnDestroy {
}
/** Determines whether a message can be described on a particular element. */
- private _canBeDescribed(element: Element, message: string): boolean {
+ private _canBeDescribed(element: Element, message: string|HTMLElement|void): boolean {
if (!this._isElementNode(element)) {
return false;
}
+ if (message && typeof message === 'object') {
+ // We'd have to make some assumptions about the description element's text, if the consumer
+ // passed in an element. Assume that if an element is passed in, the consumer has verified
+ // that it can be used as a description.
+ return true;
+ }
+
const trimmedMessage = message == null ? '' : `${message}`.trim();
const ariaLabel = element.getAttribute('aria-label');
- // We shouldn't set descriptions if they're exactly the same as the `aria-label` of the element,
- // because screen readers will end up reading out the same text twice in a row.
+ // We shouldn't set descriptions if they're exactly the same as the `aria-label` of the
+ // element, because screen readers will end up reading out the same text twice in a row.
return trimmedMessage ? (!ariaLabel || ariaLabel.trim() !== trimmedMessage) : false;
}
diff --git a/tools/public_api_guard/cdk/a11y.d.ts b/tools/public_api_guard/cdk/a11y.d.ts
index f13724f6f1a..cbe0a706ed9 100644
--- a/tools/public_api_guard/cdk/a11y.d.ts
+++ b/tools/public_api_guard/cdk/a11y.d.ts
@@ -16,9 +16,9 @@ export declare function ARIA_DESCRIBER_PROVIDER_FACTORY(parentDispatcher: AriaDe
export declare class AriaDescriber implements OnDestroy {
constructor(_document: any);
- describe(hostElement: Element, message: string): void;
+ describe(hostElement: Element, message: string | HTMLElement): void;
ngOnDestroy(): void;
- removeDescription(hostElement: Element, message: string): void;
+ removeDescription(hostElement: Element, message: string | HTMLElement): void;
}
export declare type AriaLivePoliteness = 'off' | 'polite' | 'assertive';