mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
a11y: add service to add aria-describedby labels (#6168)
* checkin * changes * a11y(tooltip): add message element for tooltip a11y * comments * remove extra line * add test for tooltip message as number * remove fit * use renderer * always decrement * add aria-describer * add test * tests * md to cdk * Add aria-hidden to container * fix aot * comments * comments * rebase * fix prerender; add a11y docs
This commit is contained in:
@@ -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) { }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import {addAriaReferencedId, getAriaReferenceIds, removeAriaReferencedId} from './aria-reference';
|
||||
|
||||
describe('AriaReference', () => {
|
||||
let testElement: HTMLElement | null;
|
||||
|
||||
beforeEach(() => {
|
||||
testElement = document.createElement('div');
|
||||
document.body.appendChild(testElement);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(testElement!);
|
||||
});
|
||||
|
||||
it('should be able to append/remove aria reference IDs', () => {
|
||||
addAriaReferencedId(testElement!, 'aria-describedby', 'reference_1');
|
||||
expectIds('aria-describedby', ['reference_1']);
|
||||
|
||||
addAriaReferencedId(testElement!, 'aria-describedby', 'reference_2');
|
||||
expectIds('aria-describedby', ['reference_1', 'reference_2']);
|
||||
|
||||
removeAriaReferencedId(testElement!, 'aria-describedby', 'reference_1');
|
||||
expectIds('aria-describedby', ['reference_2']);
|
||||
|
||||
removeAriaReferencedId(testElement!, 'aria-describedby', 'reference_2');
|
||||
expectIds('aria-describedby', []);
|
||||
});
|
||||
|
||||
it('should trim whitespace when adding/removing reference IDs', () => {
|
||||
addAriaReferencedId(testElement!, 'aria-describedby', ' reference_1 ');
|
||||
addAriaReferencedId(testElement!, 'aria-describedby', ' reference_2 ');
|
||||
expectIds('aria-describedby', ['reference_1', 'reference_2']);
|
||||
|
||||
removeAriaReferencedId(testElement!, 'aria-describedby', ' reference_1 ');
|
||||
expectIds('aria-describedby', ['reference_2']);
|
||||
|
||||
removeAriaReferencedId(testElement!, 'aria-describedby', ' reference_2 ');
|
||||
expectIds('aria-describedby', []);
|
||||
});
|
||||
|
||||
it('should ignore empty string', () => {
|
||||
addAriaReferencedId(testElement!, 'aria-describedby', ' ');
|
||||
expectIds('aria-describedby', []);
|
||||
});
|
||||
|
||||
it('should not add the same reference id if it already exists', () => {
|
||||
addAriaReferencedId(testElement!, 'aria-describedby', 'reference_1');
|
||||
addAriaReferencedId(testElement!, 'aria-describedby', 'reference_1');
|
||||
expect(['reference_1']);
|
||||
});
|
||||
|
||||
it('should retrieve ids that are deliminated by extra whitespace', () => {
|
||||
testElement!.setAttribute('aria-describedby', 'reference_1 reference_2');
|
||||
expect(getAriaReferenceIds(testElement!, 'aria-describedby'))
|
||||
.toEqual(['reference_1', 'reference_2']);
|
||||
});
|
||||
|
||||
/**
|
||||
* Expects the equal array from getAriaReferenceIds and a space-deliminated list from
|
||||
* the actual element attribute. If ids is empty, assumes the element should not have any
|
||||
* value
|
||||
*/
|
||||
function expectIds(attr: string, ids: string[]) {
|
||||
expect(getAriaReferenceIds(testElement!, attr)).toEqual(ids);
|
||||
expect(testElement!.getAttribute(attr)).toBe(ids.length ? ids.join(' ') : '');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
/** IDs are deliminated by an empty space, as per the spec. */
|
||||
const ID_DELIMINATOR = ' ';
|
||||
|
||||
/**
|
||||
* Adds the given ID to the specified ARIA attribute on an element.
|
||||
* Used for attributes such as aria-labelledby, aria-owns, etc.
|
||||
*/
|
||||
export function addAriaReferencedId(el: Element, attr: string, id: string) {
|
||||
const ids = getAriaReferenceIds(el, attr);
|
||||
if (ids.some(existingId => existingId.trim() == id.trim())) { return; }
|
||||
ids.push(id.trim());
|
||||
|
||||
el.setAttribute(attr, ids.join(ID_DELIMINATOR));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the given ID from the specified ARIA attribute on an element.
|
||||
* Used for attributes such as aria-labelledby, aria-owns, etc.
|
||||
*/
|
||||
export function removeAriaReferencedId(el: Element, attr: string, id: string) {
|
||||
const ids = getAriaReferenceIds(el, attr);
|
||||
const filteredIds = ids.filter(val => val != id.trim());
|
||||
|
||||
el.setAttribute(attr, filteredIds.join(ID_DELIMINATOR));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of IDs referenced by the given ARIA attribute on an element.
|
||||
* Used for attributes such as aria-labelledby, aria-owns, etc.
|
||||
*/
|
||||
export function getAriaReferenceIds(el: Element, attr: string): string[] {
|
||||
// Get string array of all individual ids (whitespace deliminated) in the attribute value
|
||||
return (el.getAttribute(attr) || '').match(/\S+/g) || [];
|
||||
}
|
||||
@@ -7,24 +7,32 @@
|
||||
*/
|
||||
|
||||
import {NgModule} from '@angular/core';
|
||||
import {FocusTrapDirective, FocusTrapDeprecatedDirective, FocusTrapFactory} from './focus-trap';
|
||||
import {FocusTrapDeprecatedDirective, FocusTrapDirective, FocusTrapFactory} from './focus-trap';
|
||||
import {LIVE_ANNOUNCER_PROVIDER} from './live-announcer';
|
||||
import {InteractivityChecker} from './interactivity-checker';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {PlatformModule} from '@angular/cdk/platform';
|
||||
import {AriaDescriber, ARIA_DESCRIBER_PROVIDER} from './aria-describer';
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, PlatformModule],
|
||||
declarations: [FocusTrapDirective, FocusTrapDeprecatedDirective],
|
||||
exports: [FocusTrapDirective, FocusTrapDeprecatedDirective],
|
||||
providers: [InteractivityChecker, FocusTrapFactory, LIVE_ANNOUNCER_PROVIDER]
|
||||
providers: [
|
||||
InteractivityChecker,
|
||||
FocusTrapFactory,
|
||||
AriaDescriber,
|
||||
LIVE_ANNOUNCER_PROVIDER,
|
||||
ARIA_DESCRIBER_PROVIDER
|
||||
]
|
||||
})
|
||||
export class A11yModule {}
|
||||
|
||||
export * from './live-announcer';
|
||||
export * from './activedescendant-key-manager';
|
||||
export * from './aria-describer';
|
||||
export * from './fake-mousedown';
|
||||
export * from './focus-key-manager';
|
||||
export * from './focus-trap';
|
||||
export * from './interactivity-checker';
|
||||
export * from './list-key-manager';
|
||||
export * from './activedescendant-key-manager';
|
||||
export * from './focus-key-manager';
|
||||
export * from './live-announcer';
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
<button *ngFor="let tooltip of tooltips" mdTooltip="Tooltip message">Tooltip Button</button>
|
||||
<button (click)="tooltips.push(null)">Add tooltip</button>
|
||||
<button (click)="tooltips.shift()" [disabled]="tooltips.length == 0">Remove tooltip</button>
|
||||
|
||||
<div class="demo-tooltip">
|
||||
<h1>Tooltip Demo</h1>
|
||||
|
||||
@@ -29,7 +33,7 @@
|
||||
</p>
|
||||
<p>
|
||||
<strong>Message: </strong>
|
||||
<md-form-field><input mdInput type="text" [(ngModel)]="message"></md-form-field>
|
||||
<md-form-field><input mdInput [(ngModel)]="message"></md-form-field>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {TooltipPosition} from '@angular/material';
|
||||
export class TooltipDemo {
|
||||
position: TooltipPosition = 'below';
|
||||
message: string = 'Here is the tooltip';
|
||||
tooltips: string[] = [];
|
||||
disabled = false;
|
||||
showDelay = 0;
|
||||
hideDelay = 1000;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {OverlayModule} from '@angular/cdk/overlay';
|
||||
import {PlatformModule} from '@angular/cdk/platform';
|
||||
import {MdCommonModule} from '../core';
|
||||
import {MdTooltip, TooltipComponent, MD_TOOLTIP_SCROLL_STRATEGY_PROVIDER} from './tooltip';
|
||||
import {A11yModule, ARIA_DESCRIBER_PROVIDER} from '@angular/cdk/a11y';
|
||||
|
||||
|
||||
@NgModule({
|
||||
@@ -19,12 +20,13 @@ import {MdTooltip, TooltipComponent, MD_TOOLTIP_SCROLL_STRATEGY_PROVIDER} from '
|
||||
CommonModule,
|
||||
OverlayModule,
|
||||
MdCommonModule,
|
||||
PlatformModule
|
||||
PlatformModule,
|
||||
A11yModule,
|
||||
],
|
||||
exports: [MdTooltip, TooltipComponent, MdCommonModule],
|
||||
declarations: [MdTooltip, TooltipComponent],
|
||||
entryComponents: [TooltipComponent],
|
||||
providers: [MD_TOOLTIP_SCROLL_STRATEGY_PROVIDER],
|
||||
providers: [MD_TOOLTIP_SCROLL_STRATEGY_PROVIDER, ARIA_DESCRIBER_PROVIDER],
|
||||
})
|
||||
export class MdTooltipModule {}
|
||||
|
||||
|
||||
@@ -35,3 +35,9 @@ which both accept a number in milliseconds to delay before applying the display
|
||||
|
||||
To turn off the tooltip and prevent it from showing to the user, use the `mdTooltipDisabled` input flag.
|
||||
|
||||
### Accessibility
|
||||
|
||||
Elements with the `mdTooltip` will add an `aria-describedby` label that provides a reference
|
||||
to a visually hidden element containing the tooltip's message. This provides screenreaders the
|
||||
information needed to read out the tooltip's contents when the end-user focuses on the element
|
||||
triggering the tooltip.
|
||||
@@ -6,7 +6,13 @@ import {
|
||||
TestBed,
|
||||
tick
|
||||
} from '@angular/core/testing';
|
||||
import {ChangeDetectionStrategy, Component, DebugElement, ViewChild} from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DebugElement,
|
||||
ElementRef,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {AnimationEvent} from '@angular/animations';
|
||||
import {By} from '@angular/platform-browser';
|
||||
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
|
||||
@@ -32,7 +38,12 @@ describe('MdTooltip', () => {
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [MdTooltipModule, OverlayModule, NoopAnimationsModule],
|
||||
declarations: [BasicTooltipDemo, ScrollableTooltipDemo, OnPushTooltipDemo],
|
||||
declarations: [
|
||||
BasicTooltipDemo,
|
||||
ScrollableTooltipDemo,
|
||||
OnPushTooltipDemo,
|
||||
DynamicTooltipsDemo
|
||||
],
|
||||
providers: [
|
||||
{provide: Platform, useValue: {IOS: false, isBrowser: true}},
|
||||
{provide: OverlayContainer, useFactory: () => {
|
||||
@@ -287,6 +298,21 @@ describe('MdTooltip', () => {
|
||||
expect(overlayContainerElement.textContent).toBe('');
|
||||
}));
|
||||
|
||||
it('should have an aria-described element with the tooltip message', () => {
|
||||
const dynamicTooltipsDemoFixture = TestBed.createComponent(DynamicTooltipsDemo);
|
||||
const dynamicTooltipsComponent = dynamicTooltipsDemoFixture.componentInstance;
|
||||
|
||||
dynamicTooltipsComponent.tooltips = ['Tooltip One', 'Tooltip Two'];
|
||||
dynamicTooltipsDemoFixture.detectChanges();
|
||||
|
||||
const buttons = dynamicTooltipsComponent.getButtons();
|
||||
const firstButtonAria = buttons[0].getAttribute('aria-describedby');
|
||||
expect(document.querySelector(`#${firstButtonAria}`)!.textContent).toBe('Tooltip One');
|
||||
|
||||
const secondButtonAria = buttons[1].getAttribute('aria-describedby');
|
||||
expect(document.querySelector(`#${secondButtonAria}`)!.textContent).toBe('Tooltip Two');
|
||||
});
|
||||
|
||||
it('should not try to dispose the tooltip when destroyed and done hiding', fakeAsync(() => {
|
||||
tooltipDirective.show();
|
||||
fixture.detectChanges();
|
||||
@@ -403,6 +429,13 @@ describe('MdTooltip', () => {
|
||||
expect(tooltipWrapper).toBeTruthy('Expected tooltip to be shown.');
|
||||
expect(tooltipWrapper.getAttribute('dir')).toBe('rtl', 'Expected tooltip to be in RTL mode.');
|
||||
}));
|
||||
|
||||
it('should be able to set the tooltip message as a number', fakeAsync(() => {
|
||||
fixture.componentInstance.message = 100;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(tooltipDirective.message).toBe('100');
|
||||
}));
|
||||
});
|
||||
|
||||
describe('scrollable usage', () => {
|
||||
@@ -516,7 +549,7 @@ describe('MdTooltip', () => {
|
||||
})
|
||||
class BasicTooltipDemo {
|
||||
position: string = 'below';
|
||||
message: string = initialTooltipMessage;
|
||||
message: any = initialTooltipMessage;
|
||||
showButton: boolean = true;
|
||||
showTooltipClass = false;
|
||||
@ViewChild(MdTooltip) tooltip: MdTooltip;
|
||||
@@ -565,3 +598,22 @@ class OnPushTooltipDemo {
|
||||
position: string = 'below';
|
||||
message: string = initialTooltipMessage;
|
||||
}
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app',
|
||||
template: `
|
||||
<button *ngFor="let tooltip of tooltips"
|
||||
[mdTooltip]="tooltip">
|
||||
Button {{tooltip}}
|
||||
</button>`,
|
||||
})
|
||||
class DynamicTooltipsDemo {
|
||||
tooltips: Array<string> = [];
|
||||
|
||||
constructor(private _elementRef: ElementRef) {}
|
||||
|
||||
getButtons() {
|
||||
return this._elementRef.nativeElement.querySelectorAll('button');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
ScrollStrategy,
|
||||
} from '@angular/cdk/overlay';
|
||||
import {coerceBooleanProperty} from '@angular/cdk/coercion';
|
||||
import {ESCAPE} from '@angular/cdk/keycodes';
|
||||
import {AriaDescriber} from '@angular/cdk/a11y';
|
||||
|
||||
|
||||
export type TooltipPosition = 'left' | 'right' | 'above' | 'below' | 'before' | 'after';
|
||||
@@ -77,8 +79,6 @@ export const MD_TOOLTIP_SCROLL_STRATEGY_PROVIDER = {
|
||||
deps: [Overlay],
|
||||
useFactory: MD_TOOLTIP_SCROLL_STRATEGY_PROVIDER_FACTORY
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Directive that attaches a material design tooltip to the host element. Animates the showing and
|
||||
* hiding of a tooltip provided position (defaults to below the element).
|
||||
@@ -89,6 +89,9 @@ export const MD_TOOLTIP_SCROLL_STRATEGY_PROVIDER = {
|
||||
selector: '[md-tooltip], [mdTooltip], [mat-tooltip], [matTooltip]',
|
||||
host: {
|
||||
'(longpress)': 'show()',
|
||||
'(focus)': 'show()',
|
||||
'(blur)': 'hide(0)',
|
||||
'(keydown)': '_handleKeydown($event)',
|
||||
'(touchend)': 'hide(' + TOUCHEND_HIDE_DELAY + ')',
|
||||
},
|
||||
exportAs: 'mdTooltip',
|
||||
@@ -144,8 +147,14 @@ export class MdTooltip implements OnDestroy {
|
||||
/** The message to be displayed in the tooltip */
|
||||
@Input('mdTooltip') get message() { return this._message; }
|
||||
set message(value: string) {
|
||||
this._message = value;
|
||||
this._setTooltipMessage(this._message);
|
||||
if (this._message) {
|
||||
this._ariaDescriber.removeDescription(this._elementRef.nativeElement, this._message);
|
||||
}
|
||||
|
||||
// If the message is not a string (e.g. number), convert it to a string and trim it.
|
||||
this._message = value ? `${value}`.trim() : '';
|
||||
this._updateTooltipMessage();
|
||||
this._ariaDescriber.describe(this._elementRef.nativeElement, this.message);
|
||||
}
|
||||
|
||||
/** Classes to be passed to the tooltip. Supports the same syntax as `ngClass`. */
|
||||
@@ -204,6 +213,7 @@ export class MdTooltip implements OnDestroy {
|
||||
private _viewContainerRef: ViewContainerRef,
|
||||
private _ngZone: NgZone,
|
||||
private _platform: Platform,
|
||||
private _ariaDescriber: AriaDescriber,
|
||||
@Inject(MD_TOOLTIP_SCROLL_STRATEGY) private _scrollStrategy,
|
||||
@Optional() private _dir: Directionality) {
|
||||
|
||||
@@ -229,18 +239,20 @@ export class MdTooltip implements OnDestroy {
|
||||
this._enterListener();
|
||||
this._leaveListener();
|
||||
}
|
||||
|
||||
this._ariaDescriber.removeDescription(this._elementRef.nativeElement, this.message);
|
||||
}
|
||||
|
||||
/** Shows the tooltip after the delay in ms, defaults to tooltip-delay-show or 0ms if no input */
|
||||
show(delay: number = this.showDelay): void {
|
||||
if (this.disabled || !this._message || !this._message.trim()) { return; }
|
||||
if (this.disabled || !this.message) { return; }
|
||||
|
||||
if (!this._tooltipInstance) {
|
||||
this._createTooltip();
|
||||
}
|
||||
|
||||
this._setTooltipClass(this._tooltipClass);
|
||||
this._setTooltipMessage(this._message);
|
||||
this._updateTooltipMessage();
|
||||
this._tooltipInstance!.show(this._position, delay);
|
||||
}
|
||||
|
||||
@@ -261,6 +273,14 @@ export class MdTooltip implements OnDestroy {
|
||||
return !!this._tooltipInstance && this._tooltipInstance.isVisible();
|
||||
}
|
||||
|
||||
/** Handles the keydown events on the host element. */
|
||||
_handleKeydown(e: KeyboardEvent) {
|
||||
if (this._tooltipInstance!.isVisible() && e.keyCode === ESCAPE) {
|
||||
e.stopPropagation();
|
||||
this.hide(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Create the tooltip to display */
|
||||
private _createTooltip(): void {
|
||||
let overlayRef = this._createOverlay();
|
||||
@@ -365,11 +385,11 @@ export class MdTooltip implements OnDestroy {
|
||||
}
|
||||
|
||||
/** Updates the tooltip message and repositions the overlay according to the new message length */
|
||||
private _setTooltipMessage(message: string) {
|
||||
private _updateTooltipMessage() {
|
||||
// Must wait for the message to be painted to the tooltip so that the overlay can properly
|
||||
// calculate the correct positioning based on the size of the text.
|
||||
if (this._tooltipInstance) {
|
||||
this._tooltipInstance.message = message;
|
||||
this._tooltipInstance.message = this.message;
|
||||
this._tooltipInstance._markForCheck();
|
||||
|
||||
first.call(this._ngZone.onMicrotaskEmpty).subscribe(() => {
|
||||
@@ -416,7 +436,8 @@ export type TooltipVisibility = 'initial' | 'visible' | 'hidden';
|
||||
// Forces the element to have a layout in IE and Edge. This fixes issues where the element
|
||||
// won't be rendered if the animations are disabled or there is no web animations polyfill.
|
||||
'[style.zoom]': '_visibility === "visible" ? 1 : null',
|
||||
'(body:click)': 'this._handleBodyInteraction()'
|
||||
'(body:click)': 'this._handleBodyInteraction()',
|
||||
'aria-hidden': 'true',
|
||||
}
|
||||
})
|
||||
export class TooltipComponent {
|
||||
|
||||
Reference in New Issue
Block a user