From b5c3d2f2ff1bf64d3a47a001fc1d08c3bb8d2624 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 2 Nov 2020 19:19:47 +0100 Subject: [PATCH 1/2] feat(cdk/a11y): support setting a role through AriaDescriber Allows for an optional `role` to be assigned to the description element. This is required for some cases like tooltips (see #20593). --- .../aria-describer/aria-describer.spec.ts | 66 +++++++++++++- src/cdk/a11y/aria-describer/aria-describer.ts | 91 ++++++++++++------- tools/public_api_guard/cdk/a11y.d.ts | 6 +- 3 files changed, 123 insertions(+), 40 deletions(-) diff --git a/src/cdk/a11y/aria-describer/aria-describer.spec.ts b/src/cdk/a11y/aria-describer/aria-describer.spec.ts index aa2857b29ed9..0a19a1284351 100644 --- a/src/cdk/a11y/aria-describer/aria-describer.spec.ts +++ b/src/cdk/a11y/aria-describer/aria-describer.spec.ts @@ -286,17 +286,71 @@ describe('AriaDescriber', () => { ariaDescriber.describe(component.element1, 'My Message'); expect(getMessagesContainer().getAttribute('aria-hidden')).toBe('false'); }); + + it('should be able to register the same message with different roles', () => { + createFixture(); + ariaDescriber.describe(component.element1, 'My Message', 'tooltip'); + ariaDescriber.describe(component.element2, 'My Message', 'button'); + ariaDescriber.describe(component.element3, 'My Message', 'presentation'); + expectMessages(['tooltip/My Message', 'button/My Message', 'presentation/My Message']); + expectMessage(component.element1, 'tooltip/My Message'); + expectMessage(component.element2, 'button/My Message'); + expectMessage(component.element3, 'presentation/My Message'); + }); + + it('should de-dupe a message if it has been registered with the same role', () => { + createFixture(); + ariaDescriber.describe(component.element1, 'My Message', 'tooltip'); + ariaDescriber.describe(component.element2, 'My Message', 'tooltip'); + ariaDescriber.describe(component.element3, 'My Message', 'tooltip'); + expectMessages(['tooltip/My Message']); + expectMessage(component.element1, 'tooltip/My Message'); + expectMessage(component.element2, 'tooltip/My Message'); + expectMessage(component.element3, 'tooltip/My Message'); + }); + + it('should be able to unregister messages with a particular role', () => { + createFixture(); + ariaDescriber.describe(component.element1, 'My Message', 'tooltip'); + expectMessages(['tooltip/My Message']); + + // Register again to check dedupe + ariaDescriber.describe(component.element2, 'My Message', 'tooltip'); + expectMessages(['tooltip/My Message']); + + // Unregister one message and make sure the message is still present in the container + ariaDescriber.removeDescription(component.element1, 'My Message', 'tooltip'); + expect(component.element1.hasAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE)).toBeFalsy(); + expectMessages(['tooltip/My Message']); + + // Unregister the second message, message container should be gone + ariaDescriber.removeDescription(component.element2, 'My Message', 'tooltip'); + expect(component.element2.hasAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE)).toBeFalsy(); + expect(getMessagesContainer()).toBeNull(); + }); + + it('should not remove element if it is registered with same text, but different role', () => { + createFixture(); + ariaDescriber.describe(component.element1, 'My Message', 'tooltip'); + ariaDescriber.describe(component.element2, 'My Message', 'button'); + expectMessages(['tooltip/My Message', 'button/My Message']); + ariaDescriber.removeDescription(component.element2, 'My Message', 'button'); + expectMessages(['tooltip/My Message']); + ariaDescriber.removeDescription(component.element1, 'My Message', 'tooltip'); + expect(getMessageElements()).toBeNull(); + }); + }); function getMessagesContainer() { return document.querySelector(`#${MESSAGES_CONTAINER_ID}`)!; } -function getMessageElements(): Node[] | null { +function getMessageElements(): Element[] | null { const messagesContainer = getMessagesContainer(); if (!messagesContainer) { return null; } - return messagesContainer ? Array.prototype.slice.call(messagesContainer.children) : null; + return messagesContainer ? Array.prototype.slice.call(messagesContainer.children) : null; } /** Checks that the messages array matches the existing created message elements. */ @@ -306,7 +360,9 @@ function expectMessages(messages: string[]) { expect(messages.length).toBe(messageElements!.length); messages.forEach((message, i) => { - expect(messageElements![i].textContent).toBe(message); + const element = messageElements![i]; + const role = element.getAttribute('role'); + expect((role ? role + '/' : '') + element.textContent).toBe(message); }); } @@ -320,7 +376,9 @@ function expectMessage(el: Element, message: string) { const messages = ariaDescribedBy!.split(' ').map(referenceId => { const messageElement = document.querySelector(`#${referenceId}`); - return messageElement ? messageElement.textContent : ''; + const role = messageElement?.getAttribute('role'); + const prefix = role ? role + '/' : ''; + return messageElement ? prefix + messageElement.textContent : ''; }); expect(messages).toContain(message); diff --git a/src/cdk/a11y/aria-describer/aria-describer.ts b/src/cdk/a11y/aria-describer/aria-describer.ts index 70642051d157..cc5ec0e1811a 100644 --- a/src/cdk/a11y/aria-describer/aria-describer.ts +++ b/src/cdk/a11y/aria-describer/aria-describer.ts @@ -37,7 +37,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; @@ -65,40 +65,56 @@ 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|HTMLElement) { + describe(hostElement: Element, message: string, role?: string): void; + + /** + * Adds to the host element an aria-describedby reference to an already-existing messsage element. + */ + describe(hostElement: Element, message: HTMLElement): void; + + describe(hostElement: Element, message: string|HTMLElement, role?: string): void { if (!this._canBeDescribed(hostElement, message)) { return; } + const key = getKey(message, role); + 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); + setMessageId(message); + messageRegistry.set(key, {messageElement: message, referenceCount: 0}); + } else if (!messageRegistry.has(key)) { + this._createMessageElement(message, role); } - if (!this._isElementDescribedByMessage(hostElement, message)) { - this._addMessageReference(hostElement, message); + if (!this._isElementDescribedByMessage(hostElement, key)) { + this._addMessageReference(hostElement, key); } } + /** Removes the host element's aria-describedby reference to the message. */ + removeDescription(hostElement: Element, message: string, role?: string): void; + /** Removes the host element's aria-describedby reference to the message element. */ - removeDescription(hostElement: Element, message: string|HTMLElement) { + removeDescription(hostElement: Element, message: HTMLElement): void; + + removeDescription(hostElement: Element, message: string|HTMLElement, role?: string): void { if (!message || !this._isElementNode(hostElement)) { return; } - if (this._isElementDescribedByMessage(hostElement, message)) { - this._removeMessageReference(hostElement, message); + const key = getKey(message, role); + + if (this._isElementDescribedByMessage(hostElement, key)) { + this._removeMessageReference(hostElement, key); } // 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); + const registeredMessage = messageRegistry.get(key); if (registeredMessage && registeredMessage.referenceCount === 0) { - this._deleteMessageElement(message); + this._deleteMessageElement(key); } } @@ -128,32 +144,28 @@ export class AriaDescriber implements OnDestroy { * Creates a new element in the visually hidden message container element with the message * as its content and adds it to the message registry. */ - private _createMessageElement(message: string) { + private _createMessageElement(message: string, role?: string) { const messageElement = this._document.createElement('div'); - this._setMessageId(messageElement); + setMessageId(messageElement); messageElement.textContent = message; + if (role) { + messageElement.setAttribute('role', role); + } + this._createMessagesContainer(); messagesContainer!.appendChild(messageElement); - - 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++}`; - } + messageRegistry.set(getKey(message, role), {messageElement, referenceCount: 0}); } /** Deletes the message element from the global messages container. */ - private _deleteMessageElement(message: string) { - const registeredMessage = messageRegistry.get(message); + private _deleteMessageElement(key: string|Element) { + const registeredMessage = messageRegistry.get(key); const messageElement = registeredMessage && registeredMessage.messageElement; if (messagesContainer && messageElement) { messagesContainer.removeChild(messageElement); } - messageRegistry.delete(message); + messageRegistry.delete(key); } /** Creates the global container for all aria-describedby messages. */ @@ -204,14 +216,13 @@ 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|HTMLElement) { - const registeredMessage = messageRegistry.get(message)!; + private _addMessageReference(element: Element, key: string|Element) { + const registeredMessage = 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, ''); - registeredMessage.referenceCount++; } @@ -219,8 +230,8 @@ 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|HTMLElement) { - const registeredMessage = messageRegistry.get(message)!; + private _removeMessageReference(element: Element, key: string|Element) { + const registeredMessage = messageRegistry.get(key)!; registeredMessage.referenceCount--; removeAriaReferencedId(element, 'aria-describedby', registeredMessage.messageElement.id); @@ -228,9 +239,9 @@ export class AriaDescriber implements OnDestroy { } /** Returns true if the element has been described by the provided message ID. */ - private _isElementDescribedByMessage(element: Element, message: string|HTMLElement): boolean { + private _isElementDescribedByMessage(element: Element, key: string|Element): boolean { const referenceIds = getAriaReferenceIds(element, 'aria-describedby'); - const registeredMessage = messageRegistry.get(message); + const registeredMessage = messageRegistry.get(key); const messageId = registeredMessage && registeredMessage.messageElement.id; return !!messageId && referenceIds.indexOf(messageId) != -1; @@ -262,3 +273,15 @@ export class AriaDescriber implements OnDestroy { return element.nodeType === this._document.ELEMENT_NODE; } } + +/** Gets a key that can be used to look messages up in the registry. */ +function getKey(message: string|Element, role?: string): string|Element { + return typeof message === 'string' ? `${role || ''}/${message}` : message; +} + +/** Assigns a unique ID to an element, if it doesn't have one already. */ +function setMessageId(element: HTMLElement) { + if (!element.id) { + element.id = `${CDK_DESCRIBEDBY_ID_PREFIX}-${nextId++}`; + } +} diff --git a/tools/public_api_guard/cdk/a11y.d.ts b/tools/public_api_guard/cdk/a11y.d.ts index 44132165c93c..a17aaeebabe9 100644 --- a/tools/public_api_guard/cdk/a11y.d.ts +++ b/tools/public_api_guard/cdk/a11y.d.ts @@ -12,9 +12,11 @@ export declare class ActiveDescendantKeyManager extends ListKeyManager; static ɵprov: i0.ɵɵInjectableDef; } From 1f552fc1a0c5d102ca97874fd6e8ca9de0d12c67 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 2 Nov 2020 19:21:27 +0100 Subject: [PATCH 2/2] fix(material/tooltip): assign role to aria description element Sets the `tooltip` role on the tooltip's description element so it conveys more information to assistive technology. Fixes #20593. --- src/material/tooltip/tooltip.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/material/tooltip/tooltip.ts b/src/material/tooltip/tooltip.ts index 4beab27bc0cf..f47e92cb8261 100644 --- a/src/material/tooltip/tooltip.ts +++ b/src/material/tooltip/tooltip.ts @@ -206,7 +206,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit { @Input('matTooltip') get message() { return this._message; } set message(value: string) { - this._ariaDescriber.removeDescription(this._elementRef.nativeElement, this._message); + this._ariaDescriber.removeDescription(this._elementRef.nativeElement, this._message, 'tooltip'); // If the message is not a string (e.g. number), convert it to a string and trim it. // Must convert with `String(value)`, not `${value}`, otherwise Closure Compiler optimises @@ -224,7 +224,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit { // has a data-bound `aria-label` or when it'll be set for the first time. We can avoid the // issue by deferring the description by a tick so Angular has time to set the `aria-label`. Promise.resolve().then(() => { - this._ariaDescriber.describe(this._elementRef.nativeElement, this.message); + this._ariaDescriber.describe(this._elementRef.nativeElement, this.message, 'tooltip'); }); }); } @@ -322,7 +322,7 @@ export class MatTooltip implements OnDestroy, AfterViewInit { this._destroyed.next(); this._destroyed.complete(); - this._ariaDescriber.removeDescription(nativeElement, this.message); + this._ariaDescriber.removeDescription(nativeElement, this.message, 'tooltip'); this._focusMonitor.stopMonitoring(nativeElement); }