-
Notifications
You must be signed in to change notification settings - Fork 6.8k
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
Changes from all commits
Commits
Show all changes
19 commits
Select commit
Hold shift + click to select a range
da10928
checkin
andrewseguin a965a1e
changes
andrewseguin c7538e9
a11y(tooltip): add message element for tooltip a11y
andrewseguin 7602cd7
comments
andrewseguin 0bfd0ee
remove extra line
andrewseguin d55b78b
add test for tooltip message as number
andrewseguin b7e8e10
remove fit
andrewseguin c7308df
use renderer
andrewseguin fe0756e
always decrement
andrewseguin 6e6f18b
add aria-describer
andrewseguin 6d8c7f2
add test
andrewseguin 7a928b5
tests
andrewseguin 92e2b66
md to cdk
andrewseguin 3f90703
Add aria-hidden to container
andrewseguin a98066d
fix aot
andrewseguin 3bb5435
comments
andrewseguin 31a86d1
comments
andrewseguin 0b63906
rebase
andrewseguin 2df1f71
fix prerender; add a11y docs
andrewseguin File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) { } | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 { | ||
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 | ||
}; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 toLIVE_ANNOUNCER_PROVIDER
(provides the existing instances if there is one)