Skip to content

fix: set role on tooltip description element #20953

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Nov 17, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 62 additions & 4 deletions src/cdk/a11y/aria-describer/aria-describer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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);
});
}

Expand All @@ -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);
Expand Down
91 changes: 57 additions & 34 deletions src/cdk/a11y/aria-describer/aria-describer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string|HTMLElement, RegisteredMessage>();
const messageRegistry = new Map<string|Element, RegisteredMessage>();

/** Container for all registered messages. */
let messagesContainer: HTMLElement | null = null;
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -204,33 +216,32 @@ 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++;
}

/**
* 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);
element.removeAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE);
}

/** 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;
Expand Down Expand Up @@ -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++}`;
}
}
6 changes: 3 additions & 3 deletions src/material/tooltip/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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');
});
});
}
Expand Down Expand Up @@ -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);
}

Expand Down
6 changes: 4 additions & 2 deletions tools/public_api_guard/cdk/a11y.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ export declare class ActiveDescendantKeyManager<T> extends ListKeyManager<Highli
export declare class AriaDescriber implements OnDestroy {
constructor(_document: any,
_platform?: Platform | undefined);
describe(hostElement: Element, message: string | HTMLElement): void;
describe(hostElement: Element, message: string, role?: string): void;
describe(hostElement: Element, message: HTMLElement): void;
ngOnDestroy(): void;
removeDescription(hostElement: Element, message: string | HTMLElement): void;
removeDescription(hostElement: Element, message: string, role?: string): void;
removeDescription(hostElement: Element, message: HTMLElement): void;
static ɵfac: i0.ɵɵFactoryDef<AriaDescriber, never>;
static ɵprov: i0.ɵɵInjectableDef<AriaDescriber>;
}
Expand Down