-
Notifications
You must be signed in to change notification settings - Fork 6.8k
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
Changes from all commits
74e5952
ca8b92a
2ca72a7
a30eab1
d30a360
a33eb3a
b1fee01
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,21 +1,22 @@ | ||
import { | ||
async, | ||
ComponentFixture, | ||
TestBed, | ||
tick, | ||
fakeAsync, | ||
flushMicrotasks | ||
flushMicrotasks, | ||
TestBed, | ||
tick | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'; | ||
|
@@ -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', () => { | ||
|
@@ -467,6 +495,7 @@ describe('MdTooltip', () => { | |
selector: 'app', | ||
template: ` | ||
<button *ngIf="showButton" | ||
#trigger | ||
[mdTooltip]="message" | ||
[mdTooltipPosition]="position"> | ||
Button | ||
|
@@ -476,6 +505,7 @@ class BasicTooltipDemo { | |
position: string = 'below'; | ||
message: string = initialTooltipMessage; | ||
showButton: boolean = true; | ||
@ViewChild('trigger') trigger: ElementRef; | ||
@ViewChild(MdTooltip) tooltip: MdTooltip; | ||
} | ||
|
||
|
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'; | ||
|
@@ -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'; | ||
|
||
|
@@ -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', | ||
|
@@ -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) { | ||
|
@@ -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(); | ||
|
@@ -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); | ||
} | ||
} | ||
|
@@ -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'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
} | ||
|
||
/** Sets the trigger's aria-describedby attribute. */ | ||
private _setAriaDescribedBy(ariaDescribedby: string) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This one could be replaced with an attribute binding. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
this._renderer.setAttribute( | ||
this._elementRef.nativeElement, 'aria-describedby', ariaDescribedby); | ||
} | ||
|
||
/** Create the tooltip to display */ | ||
private _createTooltip(): void { | ||
this._createOverlay(); | ||
|
@@ -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 | ||
|
@@ -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(); | ||
|
||
|
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.
Looking at this example of an accessible tooltip, it looks like we need to toggle
aria-hidden
, in addition to therole
andaria-describedby
.