Skip to content

a11y: add service to add aria-describedby labels #6168

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 19 commits into from
Sep 11, 2017
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
173 changes: 173 additions & 0 deletions src/cdk/a11y/aria-describer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import {A11yModule, CDK_DESCRIBEDBY_HOST_ATTRIBUTE} from './index';
import {AriaDescriber, MESSAGES_CONTAINER_ID} from './aria-describer';
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {Component, ElementRef, ViewChild} from '@angular/core';

describe('AriaDescriber', () => {
let ariaDescriber: AriaDescriber;
let component: TestApp;
let fixture: ComponentFixture<TestApp>;

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [A11yModule],
declarations: [TestApp],
providers: [AriaDescriber],
}).compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(TestApp);
component = fixture.componentInstance;
ariaDescriber = component.ariaDescriber;
});

afterEach(() => {
ariaDescriber.ngOnDestroy();
});

it('should initialize without the message container', () => {
expect(getMessagesContainer()).toBeNull();
});

it('should be able to create a message element', () => {
ariaDescriber.describe(component.element1, 'My Message');
expectMessages(['My Message']);
});

it('should not register empty strings', () => {
ariaDescriber.describe(component.element1, '');
expect(getMessageElements()).toBe(null);
});

it('should de-dupe a message registered multiple times', () => {
ariaDescriber.describe(component.element1, 'My Message');
ariaDescriber.describe(component.element2, 'My Message');
ariaDescriber.describe(component.element3, 'My Message');
expectMessages(['My Message']);
expectMessage(component.element1, 'My Message');
expectMessage(component.element2, 'My Message');
expectMessage(component.element3, 'My Message');
});

it('should be able to register multiple messages', () => {
ariaDescriber.describe(component.element1, 'First Message');
ariaDescriber.describe(component.element2, 'Second Message');
expectMessages(['First Message', 'Second Message']);
expectMessage(component.element1, 'First Message');
expectMessage(component.element2, 'Second Message');
});

it('should be able to unregister messages', () => {
ariaDescriber.describe(component.element1, 'My Message');
expectMessages(['My Message']);

// Register again to check dedupe
ariaDescriber.describe(component.element2, 'My Message');
expectMessages(['My Message']);

// Unregister one message and make sure the message is still present in the container
ariaDescriber.removeDescription(component.element1, 'My Message');
expect(component.element1.hasAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE)).toBeFalsy();
expectMessages(['My Message']);

// Unregister the second message, message container should be gone
ariaDescriber.removeDescription(component.element2, 'My Message');
expect(component.element2.hasAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE)).toBeFalsy();
expect(getMessagesContainer()).toBeNull();
});

it('should be able to unregister messages while having others registered', () => {
ariaDescriber.describe(component.element1, 'Persistent Message');
ariaDescriber.describe(component.element2, 'My Message');
expectMessages(['Persistent Message', 'My Message']);

// Register again to check dedupe
ariaDescriber.describe(component.element3, 'My Message');
expectMessages(['Persistent Message', 'My Message']);

// Unregister one message and make sure the message is still present in the container
ariaDescriber.removeDescription(component.element2, 'My Message');
expectMessages(['Persistent Message', 'My Message']);

// Unregister the second message, message container should be gone
ariaDescriber.removeDescription(component.element3, 'My Message');
expectMessages(['Persistent Message']);
});

it('should be able to append to an existing list of aria describedby', () => {
ariaDescriber.describe(component.element4, 'My Message');
expectMessages(['My Message']);
expectMessage(component.element4, 'My Message');
});

it('should be able to handle multiple regisitrations of the same message to an element', () => {
ariaDescriber.describe(component.element1, 'My Message');
ariaDescriber.describe(component.element1, 'My Message');
expectMessages(['My Message']);
expectMessage(component.element1, 'My Message');
});
});

function getMessagesContainer() {
return document.querySelector(`#${MESSAGES_CONTAINER_ID}`);
}

function getMessageElements(): Node[] | null {
const messagesContainer = getMessagesContainer();
if (!messagesContainer) { return null; }

return messagesContainer ? Array.prototype.slice.call(messagesContainer.children) : null;
}

/** Checks that the messages array matches the existing created message elements. */
function expectMessages(messages: string[]) {
const messageElements = getMessageElements();
expect(messageElements).toBeDefined();

expect(messages.length).toBe(messageElements!.length);
messages.forEach((message, i) => {
expect(messageElements![i].textContent).toBe(message);
});
}

/** Checks that an element points to a message element that contains the message. */
function expectMessage(el: Element, message: string) {
const ariaDescribedBy = el.getAttribute('aria-describedby');
expect(ariaDescribedBy).toBeDefined();

const cdkDescribedBy = el.getAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE);
expect(cdkDescribedBy).toBeDefined();

const messages = ariaDescribedBy!.split(' ').map(referenceId => {
const messageElement = document.querySelector(`#${referenceId}`);
return messageElement ? messageElement.textContent : '';
});

expect(messages).toContain(message);
}

@Component({
template: `
<div #element1></div>
<div #element2></div>
<div #element3></div>
<div #element4 aria-describedby="existing-aria-describedby1 existing-aria-describedby2"></div>
`,
})
class TestApp {
@ViewChild('element1') _element1: ElementRef;
get element1(): Element { return this._element1.nativeElement; }

@ViewChild('element2') _element2: ElementRef;
get element2(): Element { return this._element2.nativeElement; }

@ViewChild('element3') _element3: ElementRef;
get element3(): Element { return this._element3.nativeElement; }

@ViewChild('element4') _element4: ElementRef;
get element4(): Element { return this._element4.nativeElement; }


constructor(public ariaDescriber: AriaDescriber) { }
}
199 changes: 199 additions & 0 deletions src/cdk/a11y/aria-describer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {Injectable, Optional, SkipSelf} from '@angular/core';
import {Platform} from '@angular/cdk/platform';
import {addAriaReferencedId, getAriaReferenceIds, removeAriaReferencedId} from './aria-reference';

/**
* Interface used to register message elements and keep a count of how many registrations have
* the same message and the reference to the message element used for the aria-describedby.
*/
export interface RegisteredMessage {
messageElement: Element;
referenceCount: number;
}

/** ID used for the body container where all messages are appended. */
export const MESSAGES_CONTAINER_ID = 'cdk-describedby-message-container';

/** ID prefix used for each created message element. */
export const CDK_DESCRIBEDBY_ID_PREFIX = 'cdk-describedby-message';

/** Attribute given to each host element that is described by a message element. */
export const CDK_DESCRIBEDBY_HOST_ATTRIBUTE = 'cdk-describedby-host';

/** Global incremental identifier for each registered message element. */
let nextId = 0;

/** Global map of all registered message elements that have been placed into the document. */
const messageRegistry = new Map<string, RegisteredMessage>();

/** Container for all registered messages. */
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()
export class AriaDescriber {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to add a provider for AriaDescriber similar to LIVE_ANNOUNCER_PROVIDER
(provides the existing instances if there is one)

constructor(private _platform: Platform) { }

/**
* Adds to the host element an aria-describedby reference to a hidden element that contains
* the message. If the same message has already been registered, then it will reuse the created
* message element.
*/
describe(hostElement: Element, message: string) {
if (!this._platform.isBrowser || !`${message}`.trim()) { return; }

if (!messageRegistry.has(message)) {
createMessageElement(message);
}

if (!isElementDescribedByMessage(hostElement, message)) {
addMessageReference(hostElement, message);
}
}

/** Removes the host element's aria-describedby reference to the message element. */
removeDescription(hostElement: Element, message: string) {
if (!this._platform.isBrowser || !`${message}`.trim()) {
return;
}

if (isElementDescribedByMessage(hostElement, message)) {
removeMessageReference(hostElement, message);
}

if (messageRegistry.get(message)!.referenceCount === 0) {
deleteMessageElement(message);
}

if (messagesContainer!.childNodes.length === 0) {
deleteMessagesContainer();
}
}

/** Unregisters all created message elements and removes the message container. */
ngOnDestroy() {
if (!this._platform.isBrowser) { return; }

const describedElements = document.querySelectorAll(`[${CDK_DESCRIBEDBY_HOST_ATTRIBUTE}]`);
for (let i = 0; i < describedElements.length; i++) {
removeCdkDescribedByReferenceIds(describedElements[i]);
describedElements[i].removeAttribute(CDK_DESCRIBEDBY_HOST_ATTRIBUTE);
}

if (messagesContainer) {
deleteMessagesContainer();
}

messageRegistry.clear();
}
}

/**
* Creates a new element in the visually hidden message container element with the message
* as its content and adds it to the message registry.
*/
function createMessageElement(message: string) {
const messageElement = document.createElement('div');
messageElement.setAttribute('id', `${CDK_DESCRIBEDBY_ID_PREFIX}-${nextId++}`);
messageElement.appendChild(document.createTextNode(message)!);

if (!messagesContainer) { createMessagesContainer(); }
messagesContainer!.appendChild(messageElement);

messageRegistry.set(message, {messageElement, referenceCount: 0});
}

/** Deletes the message element from the global messages container. */
function deleteMessageElement(message: string) {
const messageElement = messageRegistry.get(message)!.messageElement;
messagesContainer!.removeChild(messageElement);
messageRegistry.delete(message);
}

/** Creates the global container for all aria-describedby messages. */
function createMessagesContainer() {
messagesContainer = document.createElement('div');

messagesContainer.setAttribute('id', MESSAGES_CONTAINER_ID);
messagesContainer.setAttribute('aria-hidden', 'true');
messagesContainer.style.display = 'none';
document.body.appendChild(messagesContainer);
}

/** Deletes the global messages container. */
function deleteMessagesContainer() {
document.body.removeChild(messagesContainer!);
messagesContainer = null;
}

/** Removes all cdk-describedby messages that are hosted through the element. */
function removeCdkDescribedByReferenceIds(element: Element) {
// Remove all aria-describedby reference IDs that are prefixed by CDK_DESCRIBEDBY_ID_PREFIX
const originalReferenceIds = getAriaReferenceIds(element, 'aria-describedby')
.filter(id => id.indexOf(CDK_DESCRIBEDBY_ID_PREFIX) != 0);
element.setAttribute('aria-describedby', originalReferenceIds.join(' '));
}

/**
* Adds a message reference to the element using aria-describedby and increments the registered
* message's reference count.
*/
function addMessageReference(element: Element, message: string) {
const registeredMessage = messageRegistry.get(message)!;

// 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.
*/
function removeMessageReference(element: Element, message: string) {
const registeredMessage = messageRegistry.get(message)!;
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. */
function isElementDescribedByMessage(element: Element, message: string) {
const referenceIds = getAriaReferenceIds(element, 'aria-describedby');
const messageId = messageRegistry.get(message)!.messageElement.id;

return referenceIds.indexOf(messageId) != -1;
}

/** @docs-private */
export function ARIA_DESCRIBER_PROVIDER_FACTORY(
parentDispatcher: AriaDescriber, platform: Platform) {
return parentDispatcher || new AriaDescriber(platform);
}

/** @docs-private */
export const ARIA_DESCRIBER_PROVIDER = {
// If there is already an AriaDescriber available, use that. Otherwise, provide a new one.
provide: AriaDescriber,
deps: [
[new Optional(), new SkipSelf(), AriaDescriber],
Platform
],
useFactory: ARIA_DESCRIBER_PROVIDER_FACTORY
};
Loading