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

* Adds the ability to pass in an existing element as the message to `AriaDescriber.describe`.
* Removes the `@docs-private` from the `AriaDescriber` so that it's available in the API docs.

Fixes #16099.
This commit is contained in:
Kristiyan Kostadinov
2019-06-07 16:42:45 +02:00
committed by Joey Perrott
parent f6903da4bb
commit 8c4f25f341
3 changed files with 127 additions and 18 deletions
@@ -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) {
<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 {
+37 -16
View File
@@ -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;
@@ -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;
}
+2 -2
View File
@@ -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';