mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
committed by
Joey Perrott
parent
f6903da4bb
commit
8c4f25f341
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+2
-2
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user