diff --git a/src/cdk/a11y/aria-describer.spec.ts b/src/cdk/a11y/aria-describer.spec.ts new file mode 100644 index 000000000000..93164d3a518c --- /dev/null +++ b/src/cdk/a11y/aria-describer.spec.ts @@ -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; + + 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: ` +
+
+
+
+ `, +}) +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) { } +} diff --git a/src/cdk/a11y/aria-describer.ts b/src/cdk/a11y/aria-describer.ts new file mode 100644 index 000000000000..cf8e7df13aa5 --- /dev/null +++ b/src/cdk/a11y/aria-describer.ts @@ -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(); + +/** 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 +}; diff --git a/src/cdk/a11y/aria-reference.spec.ts b/src/cdk/a11y/aria-reference.spec.ts new file mode 100644 index 000000000000..849bafb561e3 --- /dev/null +++ b/src/cdk/a11y/aria-reference.spec.ts @@ -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(' ') : ''); + } +}); diff --git a/src/cdk/a11y/aria-reference.ts b/src/cdk/a11y/aria-reference.ts new file mode 100644 index 000000000000..4202890a95bf --- /dev/null +++ b/src/cdk/a11y/aria-reference.ts @@ -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) || []; +} diff --git a/src/cdk/a11y/public_api.ts b/src/cdk/a11y/public_api.ts index 2d327ccc0a20..588e73a804c4 100644 --- a/src/cdk/a11y/public_api.ts +++ b/src/cdk/a11y/public_api.ts @@ -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'; diff --git a/src/demo-app/tooltip/tooltip-demo.html b/src/demo-app/tooltip/tooltip-demo.html index b53bcb4ffa08..32845a31bf0f 100644 --- a/src/demo-app/tooltip/tooltip-demo.html +++ b/src/demo-app/tooltip/tooltip-demo.html @@ -1,3 +1,7 @@ + + + +

Tooltip Demo

@@ -29,7 +33,7 @@

Tooltip Demo

Message: - +

diff --git a/src/demo-app/tooltip/tooltip-demo.ts b/src/demo-app/tooltip/tooltip-demo.ts index 44801dd490f3..95b79d9f49b9 100644 --- a/src/demo-app/tooltip/tooltip-demo.ts +++ b/src/demo-app/tooltip/tooltip-demo.ts @@ -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; diff --git a/src/lib/tooltip/index.ts b/src/lib/tooltip/index.ts index 1fa88320c3e2..f16ad06caff9 100644 --- a/src/lib/tooltip/index.ts +++ b/src/lib/tooltip/index.ts @@ -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 {} diff --git a/src/lib/tooltip/tooltip.md b/src/lib/tooltip/tooltip.md index 56bf51ceab75..5359617aebcd 100644 --- a/src/lib/tooltip/tooltip.md +++ b/src/lib/tooltip/tooltip.md @@ -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. \ No newline at end of file diff --git a/src/lib/tooltip/tooltip.spec.ts b/src/lib/tooltip/tooltip.spec.ts index cb451e3807e6..a251597ddc52 100644 --- a/src/lib/tooltip/tooltip.spec.ts +++ b/src/lib/tooltip/tooltip.spec.ts @@ -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: ` + `, +}) +class DynamicTooltipsDemo { + tooltips: Array = []; + + constructor(private _elementRef: ElementRef) {} + + getButtons() { + return this._elementRef.nativeElement.querySelectorAll('button'); + } +} diff --git a/src/lib/tooltip/tooltip.ts b/src/lib/tooltip/tooltip.ts index 1a4f1d699afe..1544fa0bcaad 100644 --- a/src/lib/tooltip/tooltip.ts +++ b/src/lib/tooltip/tooltip.ts @@ -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 _ngZone: NgZone, private _renderer: Renderer2, 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 {