Skip to content

feat(a11y): allow for element to be used as message in AriaDescriber #16118

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
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
88 changes: 88 additions & 0 deletions src/cdk/a11y/aria-describer/aria-describer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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');
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -186,6 +272,8 @@ function expectMessage(el: Element, message: string) {
<div #element2></div>
<div #element3></div>
<div #element4 aria-describedby="existing-aria-describedby1 existing-aria-describedby2"></div>
<div id="description-with-existing-id">Hello</div>
<div description-without-id>Hey</div>
`,
})
class TestApp {
Expand Down
53 changes: 37 additions & 16 deletions src/cdk/a11y/aria-describer/aria-describer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, RegisteredMessage>();
const messageRegistry = new Map<string|HTMLElement, RegisteredMessage>();

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

Expand All @@ -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;
}
Expand All @@ -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) {
Expand Down Expand Up @@ -124,15 +131,22 @@ 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);

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);
Expand Down Expand Up @@ -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
Expand All @@ -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--;

Expand All @@ -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;
Expand All @@ -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;
}

Expand Down
4 changes: 2 additions & 2 deletions tools/public_api_guard/cdk/a11y.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down