Skip to content

fix(tooltip): improve accessibility #4688

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

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/lib/tooltip/tooltip.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<div class="mat-tooltip"
role="tooltip"
Copy link
Member

Choose a reason for hiding this comment

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

Looking at this example of an accessible tooltip, it looks like we need to toggle aria-hidden, in addition to the role and aria-describedby.

[id]="id"
[style.transform-origin]="_transformOrigin"
[@state]="_visibility"
(@state.done)="_afterVisibilityAnimation($event)">
Expand Down
42 changes: 36 additions & 6 deletions src/lib/tooltip/tooltip.spec.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import {
async,
ComponentFixture,
TestBed,
tick,
fakeAsync,
flushMicrotasks
flushMicrotasks,
TestBed,
tick
Copy link
Member

Choose a reason for hiding this comment

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

Needs a trailing comma here and on line 14.

} from '@angular/core/testing';
import {
ChangeDetectionStrategy,
Component,
DebugElement,
ViewChild,
ChangeDetectionStrategy
ElementRef,
ViewChild
} from '@angular/core';
import {AnimationEvent} from '@angular/animations';
import {By} from '@angular/platform-browser';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {TooltipPosition, MdTooltip, MdTooltipModule, SCROLL_THROTTLE_MS} from './index';
import {MdTooltip, MdTooltipModule, SCROLL_THROTTLE_MS, TooltipPosition} from './index';
import {OverlayContainer} from '../core';
import {Dir, LayoutDirection} from '../core/rtl/dir';
import {OverlayModule} from '../core/overlay/overlay-directives';
Expand Down Expand Up @@ -366,6 +367,33 @@ 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 associate trigger and tooltip through aria-describedby', fakeAsync(() => {
// Expect that the trigger does not have an associated tooltip since there is no tooltip shown
const trigger = fixture.componentInstance.trigger.nativeElement;
expect(trigger.getAttribute('aria-describedBy')).toBe(null);

tooltipDirective.show(0);
tick(0); // Tick for the show delay

// Tooltip is shown, check that the tooltip's id matches the aria-describedby
fixture.detectChanges();
expect(tooltipDirective._getTooltipId()).toContain('md-tooltip-');
expect(tooltipDirective._tooltipInstance.id).toContain('md-tooltip-');
expect(trigger.getAttribute('aria-describedBy')).toContain('md-tooltip-');

tooltipDirective.hide(0);
tick(0); // Tick for the hide delay
fixture.detectChanges();

// Let animation play out and resolve to remove the tooltip
flushMicrotasks();
fixture.detectChanges();

// Tooltip is hidden again, check that the trigger does not have an aria-describedby
expect(tooltipDirective._getTooltipId()).toBe('');
expect(trigger.getAttribute('aria-describedBy')).toBe('');
}));
});

describe('scrollable usage', () => {
Expand Down Expand Up @@ -467,6 +495,7 @@ describe('MdTooltip', () => {
selector: 'app',
template: `
<button *ngIf="showButton"
#trigger
[mdTooltip]="message"
[mdTooltipPosition]="position">
Button
Expand All @@ -476,6 +505,7 @@ class BasicTooltipDemo {
position: string = 'below';
message: string = initialTooltipMessage;
showButton: boolean = true;
@ViewChild('trigger') trigger: ElementRef;
@ViewChild(MdTooltip) tooltip: MdTooltip;
}

Expand Down
93 changes: 64 additions & 29 deletions src/lib/tooltip/tooltip.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,24 @@
import {
ChangeDetectorRef,
Component,
Directive,
Input,
ElementRef,
ViewContainerRef,
Input,
NgZone,
Optional,
OnDestroy,
Optional,
Renderer2,
ChangeDetectorRef,
ViewContainerRef
} from '@angular/core';
import {animate, AnimationEvent, state, style, transition, trigger} from '@angular/animations';
import {
style,
trigger,
state,
transition,
animate,
AnimationEvent,
} from '@angular/animations';
import {
Overlay,
OverlayState,
OverlayRef,
ComponentPortal,
OverlayConnectionPosition,
OriginConnectionPosition,
RepositionScrollStrategy,
Overlay,
OverlayConnectionPosition,
OverlayRef,
OverlayState,
RepositionScrollStrategy
} from '../core';
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';
Expand All @@ -34,6 +27,7 @@ import {Platform} from '../core/platform/index';
import 'rxjs/add/operator/first';
import {ScrollDispatcher} from '../core/overlay/scroll/scroll-dispatcher';
import {coerceBooleanProperty} from '../core/coercion/boolean-property';
import {ESCAPE} from '../core/keyboard/keycodes';

export type TooltipPosition = 'left' | 'right' | 'above' | 'below' | 'before' | 'after';

Expand All @@ -58,6 +52,9 @@ export function throwMdTooltipInvalidPositionError(position: string) {
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',
Expand Down Expand Up @@ -149,15 +146,14 @@ export class MdTooltip implements OnDestroy {
set _matShowDelay(v) { this.showDelay = v; }

constructor(
private _overlay: Overlay,
private _elementRef: ElementRef,
private _scrollDispatcher: ScrollDispatcher,
private _viewContainerRef: ViewContainerRef,
private _ngZone: NgZone,
private _renderer: Renderer2,
private _platform: Platform,
@Optional() private _dir: Dir) {

private _overlay: Overlay,
private _elementRef: ElementRef,
private _scrollDispatcher: ScrollDispatcher,
private _viewContainerRef: ViewContainerRef,
private _ngZone: NgZone,
private _renderer: Renderer2,
private _platform: Platform,
@Optional() private _dir: Dir) {
// The mouse events shouldn't be bound on iOS devices, because
// they can prevent the first tap from firing its click event.
if (!_platform.IOS) {
Expand All @@ -166,9 +162,7 @@ export class MdTooltip implements OnDestroy {
}
}

/**
* Dispose the tooltip when destroyed.
*/
/** Dispose the tooltip when destroyed. */
ngOnDestroy() {
if (this._tooltipInstance) {
this._disposeTooltip();
Expand All @@ -183,13 +177,23 @@ export class MdTooltip implements OnDestroy {
this._createTooltip();
}

// If the user has not already set an aria-describedby, then use the tooltip's id.
if (!this._getAriaDescribedby()) {
this._setAriaDescribedBy(this._tooltipInstance.id);
}

this._setTooltipMessage(this._message);
this._tooltipInstance.show(this._position, delay);
}

/** Hides the tooltip after the delay in ms, defaults to tooltip-delay-hide or 0ms if no input */
hide(delay: number = this.hideDelay): void {
if (this._tooltipInstance) {
// Remove the aria-describedby attribute if it matches the now-hidden tooltip's id
if (this._getAriaDescribedby() === this._tooltipInstance.id) {
this._setAriaDescribedBy('');
}

this._tooltipInstance.hide(delay);
}
}
Expand All @@ -199,11 +203,36 @@ export class MdTooltip implements OnDestroy {
this._isTooltipVisible() ? this.hide() : this.show();
}

_getTooltipId(): string {
return this._tooltipInstance ? this._tooltipInstance.id : '';
}

/** Returns true if the tooltip is currently visible to the user */
_isTooltipVisible(): boolean {
return !!this._tooltipInstance && this._tooltipInstance.isVisible();
}

/** Handles the keydown events on the host element. */
_handleKeydown(e: KeyboardEvent) {
// If the tooltip is visible and the user pressed escape,
// intercept the event and hide the tooltip.
if (this._tooltipInstance && this._tooltipInstance.isVisible() && e.keyCode === ESCAPE) {
e.stopPropagation();
this.hide(0);
}
}

/** Returns the trigger's aria-describedby attribute. */
private _getAriaDescribedby(): string {
return this._elementRef.nativeElement.getAttribute('aria-describedby');
Copy link
Member

Choose a reason for hiding this comment

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

This one is technically safe from throwing an error in Universal, because it's after a user interaction, but it might be better if we used an @Input anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree except I think we need would need this anyways since we're checking if the current tooltip matches the aria-describedby and an Input@ won't capture our setter's interaction.

}

/** Sets the trigger's aria-describedby attribute. */
private _setAriaDescribedBy(ariaDescribedby: string) {
Copy link
Member

Choose a reason for hiding this comment

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

This one could be replaced with an attribute binding.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I had that at first but we saw that it would clobber any existing aria-describedby attribute that the user may have set. We want to only apply the tooltip's id as aria-describedby if there isn't already one set.

this._renderer.setAttribute(
this._elementRef.nativeElement, 'aria-describedby', ariaDescribedby);
}

/** Create the tooltip to display */
private _createTooltip(): void {
this._createOverlay();
Expand Down Expand Up @@ -317,6 +346,9 @@ export class MdTooltip implements OnDestroy {

export type TooltipVisibility = 'initial' | 'visible' | 'hidden';

/** Tooltip IDs need to be unique, so this counter exists outside of the component definition. */
let _uniqueTooltipIdCounter = 0;

/**
* Internal component that wraps the tooltip's content.
* @docs-private
Expand Down Expand Up @@ -359,6 +391,9 @@ export class TooltipComponent {
/** The transform origin used in the animation for showing and hiding the tooltip */
_transformOrigin: string = 'bottom';

/** Unique ID to be used by the tooltip trigger's "aria-describedby" property. */
id: string = `md-tooltip-${_uniqueTooltipIdCounter++}`;

/** Subject for notifying that the tooltip has been hidden from the view */
private _onHide: Subject<any> = new Subject();

Expand Down