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';