Skip to content

Commit e5f303b

Browse files
committed
fix(material/tooltip): don't hide when pointer moves to tooltip
Currently we hide the tooltip as soon as the pointer leaves the trigger element which may be problematic with larger cursors that partially obstruct the content. These changes allow hover events on the tooltip and add extra logic so that moving to it doesn't start the hiding sequence. Fixes #4942.
1 parent 8a12da7 commit e5f303b

File tree

7 files changed

+218
-19
lines changed

7 files changed

+218
-19
lines changed

src/material-experimental/mdc-tooltip/tooltip.scss

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,18 @@
44
@include tooltip.core-styles($query: structure);
55

66
.mat-mdc-tooltip {
7-
// We don't use MDC's positioning so this has to be static.
8-
position: static;
7+
// We don't use MDC's positioning so this has to be relative.
8+
position: relative;
99

10-
// The overlay reference updates the pointer-events style property directly on the HTMLElement
11-
// depending on the state of the overlay. For tooltips the overlay panel should never enable
12-
// pointer events. To overwrite the inline CSS from the overlay reference `!important` is needed.
13-
pointer-events: none !important;
10+
// Increases the area of the tooltip so the user's pointer can go from the trigger directly to it.
11+
&::before {
12+
$offset: -8px;
13+
content: '';
14+
top: $offset;
15+
right: $offset;
16+
bottom: $offset;
17+
left: $offset;
18+
z-index: -1;
19+
position: absolute;
20+
}
1421
}

src/material-experimental/mdc-tooltip/tooltip.spec.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {Platform} from '@angular/cdk/platform';
77
import {
88
createFakeEvent,
99
createKeyboardEvent,
10+
createMouseEvent,
1011
dispatchEvent,
1112
dispatchFakeEvent,
1213
dispatchKeyboardEvent,
@@ -926,6 +927,91 @@ describe('MDC-based MatTooltip', () => {
926927
expect(tooltipElement.classList).toContain('mdc-tooltip--multiline');
927928
expect(tooltipDirective._tooltipInstance?._isMultiline).toBeTrue();
928929
}));
930+
931+
it('should hide on mouseleave on the trigger', fakeAsync(() => {
932+
// We don't bind mouse events on mobile devices.
933+
if (platform.IOS || platform.ANDROID) {
934+
return;
935+
}
936+
937+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
938+
fixture.detectChanges();
939+
tick(0);
940+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
941+
942+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseleave');
943+
fixture.detectChanges();
944+
tick(0);
945+
expect(tooltipDirective._isTooltipVisible()).toBe(false);
946+
}));
947+
948+
it('should not hide on mouseleave if the pointer goes from the trigger to the tooltip', fakeAsync(() => {
949+
// We don't bind mouse events on mobile devices.
950+
if (platform.IOS || platform.ANDROID) {
951+
return;
952+
}
953+
954+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
955+
fixture.detectChanges();
956+
tick(0);
957+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
958+
959+
const tooltipElement = overlayContainerElement.querySelector(
960+
'.mat-mdc-tooltip',
961+
) as HTMLElement;
962+
const event = createMouseEvent('mouseleave');
963+
Object.defineProperty(event, 'relatedTarget', {value: tooltipElement});
964+
965+
dispatchEvent(fixture.componentInstance.button.nativeElement, event);
966+
fixture.detectChanges();
967+
tick(0);
968+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
969+
}));
970+
971+
it('should hide on mouseleave on the tooltip', fakeAsync(() => {
972+
// We don't bind mouse events on mobile devices.
973+
if (platform.IOS || platform.ANDROID) {
974+
return;
975+
}
976+
977+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
978+
fixture.detectChanges();
979+
tick(0);
980+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
981+
982+
const tooltipElement = overlayContainerElement.querySelector(
983+
'.mat-mdc-tooltip',
984+
) as HTMLElement;
985+
dispatchMouseEvent(tooltipElement, 'mouseleave');
986+
fixture.detectChanges();
987+
tick(0);
988+
expect(tooltipDirective._isTooltipVisible()).toBe(false);
989+
}));
990+
991+
it('should not hide on mouseleave if the pointer goes from the tooltip to the trigger', fakeAsync(() => {
992+
// We don't bind mouse events on mobile devices.
993+
if (platform.IOS || platform.ANDROID) {
994+
return;
995+
}
996+
997+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
998+
fixture.detectChanges();
999+
tick(0);
1000+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
1001+
1002+
const tooltipElement = overlayContainerElement.querySelector(
1003+
'.mat-mdc-tooltip',
1004+
) as HTMLElement;
1005+
const event = createMouseEvent('mouseleave');
1006+
Object.defineProperty(event, 'relatedTarget', {
1007+
value: fixture.componentInstance.button.nativeElement,
1008+
});
1009+
1010+
dispatchEvent(tooltipElement, event);
1011+
fixture.detectChanges();
1012+
tick(0);
1013+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
1014+
}));
9291015
});
9301016

9311017
describe('fallback positions', () => {

src/material-experimental/mdc-tooltip/tooltip.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,13 @@ export class MatTooltip extends _MatTooltipBase<TooltipComponent> {
121121
// Forces the element to have a layout in IE and Edge. This fixes issues where the element
122122
// won't be rendered if the animations are disabled or there is no web animations polyfill.
123123
'[style.zoom]': '_visibility === "visible" ? 1 : null',
124+
'(mouseleave)': '_handleMouseLeave($event)',
124125
'aria-hidden': 'true',
125126
},
126127
})
127128
export class TooltipComponent extends _TooltipComponentBase {
128129
/* Whether the tooltip text overflows to multiple lines */
129-
_isMultiline: boolean = false;
130+
_isMultiline = false;
130131

131132
constructor(changeDetectorRef: ChangeDetectorRef, private _elementRef: ElementRef) {
132133
super(changeDetectorRef);

src/material/tooltip/tooltip.scss

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,6 @@ $margin: 14px;
77
$handset-horizontal-padding: 16px;
88
$handset-margin: 24px;
99

10-
.mat-tooltip-panel {
11-
// The overlay reference updates the pointer-events style property directly on the HTMLElement
12-
// depending on the state of the overlay. For tooltips the overlay panel should never enable
13-
// pointer events. To overwrite the inline CSS from the overlay reference `!important` is needed.
14-
pointer-events: none !important;
15-
}
16-
1710
.mat-tooltip {
1811
color: white;
1912
border-radius: 4px;

src/material/tooltip/tooltip.spec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {Platform} from '@angular/cdk/platform';
77
import {
88
createFakeEvent,
99
createKeyboardEvent,
10+
createMouseEvent,
1011
dispatchEvent,
1112
dispatchFakeEvent,
1213
dispatchKeyboardEvent,
@@ -903,6 +904,85 @@ describe('MatTooltip', () => {
903904
// throw if we have any timers by the end of the test.
904905
fixture.destroy();
905906
}));
907+
908+
it('should hide on mouseleave on the trigger', fakeAsync(() => {
909+
// We don't bind mouse events on mobile devices.
910+
if (platform.IOS || platform.ANDROID) {
911+
return;
912+
}
913+
914+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
915+
fixture.detectChanges();
916+
tick(0);
917+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
918+
919+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseleave');
920+
fixture.detectChanges();
921+
tick(0);
922+
expect(tooltipDirective._isTooltipVisible()).toBe(false);
923+
}));
924+
925+
it('should not hide on mouseleave if the pointer goes from the trigger to the tooltip', fakeAsync(() => {
926+
// We don't bind mouse events on mobile devices.
927+
if (platform.IOS || platform.ANDROID) {
928+
return;
929+
}
930+
931+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
932+
fixture.detectChanges();
933+
tick(0);
934+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
935+
936+
const tooltipElement = overlayContainerElement.querySelector('.mat-tooltip') as HTMLElement;
937+
const event = createMouseEvent('mouseleave');
938+
Object.defineProperty(event, 'relatedTarget', {value: tooltipElement});
939+
940+
dispatchEvent(fixture.componentInstance.button.nativeElement, event);
941+
fixture.detectChanges();
942+
tick(0);
943+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
944+
}));
945+
946+
it('should hide on mouseleave on the tooltip', fakeAsync(() => {
947+
// We don't bind mouse events on mobile devices.
948+
if (platform.IOS || platform.ANDROID) {
949+
return;
950+
}
951+
952+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
953+
fixture.detectChanges();
954+
tick(0);
955+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
956+
957+
const tooltipElement = overlayContainerElement.querySelector('.mat-tooltip') as HTMLElement;
958+
dispatchMouseEvent(tooltipElement, 'mouseleave');
959+
fixture.detectChanges();
960+
tick(0);
961+
expect(tooltipDirective._isTooltipVisible()).toBe(false);
962+
}));
963+
964+
it('should not hide on mouseleave if the pointer goes from the tooltip to the trigger', fakeAsync(() => {
965+
// We don't bind mouse events on mobile devices.
966+
if (platform.IOS || platform.ANDROID) {
967+
return;
968+
}
969+
970+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
971+
fixture.detectChanges();
972+
tick(0);
973+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
974+
975+
const tooltipElement = overlayContainerElement.querySelector('.mat-tooltip') as HTMLElement;
976+
const event = createMouseEvent('mouseleave');
977+
Object.defineProperty(event, 'relatedTarget', {
978+
value: fixture.componentInstance.button.nativeElement,
979+
});
980+
981+
dispatchEvent(tooltipElement, event);
982+
fixture.detectChanges();
983+
tick(0);
984+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
985+
}));
906986
});
907987

908988
describe('fallback positions', () => {

src/material/tooltip/tooltip.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
import {ESCAPE, hasModifierKey} from '@angular/cdk/keycodes';
1818
import {BreakpointObserver, Breakpoints, BreakpointState} from '@angular/cdk/layout';
1919
import {
20-
ConnectedPosition,
2120
FlexibleConnectedPositionStrategy,
2221
HorizontalConnectionPos,
2322
OriginConnectionPosition,
@@ -27,6 +26,7 @@ import {
2726
ScrollStrategy,
2827
VerticalConnectionPos,
2928
ConnectionPositionPair,
29+
ConnectedPosition,
3030
} from '@angular/cdk/overlay';
3131
import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform';
3232
import {ComponentPortal, ComponentType} from '@angular/cdk/portal';
@@ -207,6 +207,10 @@ export abstract class _MatTooltipBase<T extends _TooltipComponentBase>
207207
}
208208
set hideDelay(value: NumberInput) {
209209
this._hideDelay = coerceNumberProperty(value);
210+
211+
if (this._tooltipInstance) {
212+
this._tooltipInstance._mouseLeaveHideDelay = this._hideDelay;
213+
}
210214
}
211215
private _hideDelay = this._defaultOptions.hideDelay;
212216

@@ -376,14 +380,16 @@ export abstract class _MatTooltipBase<T extends _TooltipComponentBase>
376380
this._detach();
377381
this._portal =
378382
this._portal || new ComponentPortal(this._tooltipComponent, this._viewContainerRef);
379-
this._tooltipInstance = overlayRef.attach(this._portal).instance;
380-
this._tooltipInstance
383+
const instance = (this._tooltipInstance = overlayRef.attach(this._portal).instance);
384+
instance._triggerElement = this._elementRef.nativeElement;
385+
instance._mouseLeaveHideDelay = this._hideDelay;
386+
instance
381387
.afterHidden()
382388
.pipe(takeUntil(this._destroyed))
383389
.subscribe(() => this._detach());
384390
this._setTooltipClass(this._tooltipClass);
385391
this._updateTooltipMessage();
386-
this._tooltipInstance!.show(delay);
392+
instance.show(delay);
387393
}
388394

389395
/** Hides the tooltip after the delay in ms, defaults to tooltip-delay-hide or 0ms if no input */
@@ -687,7 +693,15 @@ export abstract class _MatTooltipBase<T extends _TooltipComponentBase>
687693
const exitListeners: (readonly [string, EventListenerOrEventListenerObject])[] = [];
688694
if (this._platformSupportsMouseEvents()) {
689695
exitListeners.push(
690-
['mouseleave', () => this.hide()],
696+
[
697+
'mouseleave',
698+
event => {
699+
const newTarget = (event as MouseEvent).relatedTarget as Node | null;
700+
if (!newTarget || !this._overlayRef?.overlayElement.contains(newTarget)) {
701+
this.hide();
702+
}
703+
},
704+
],
691705
['wheel', event => this._wheelListener(event as WheelEvent)],
692706
);
693707
} else if (this.touchGestures !== 'off') {
@@ -824,6 +838,12 @@ export abstract class _TooltipComponentBase implements OnDestroy {
824838
/** Property watched by the animation framework to show or hide the tooltip */
825839
_visibility: TooltipVisibility = 'initial';
826840

841+
/** Element that caused the tooltip to open. */
842+
_triggerElement: HTMLElement;
843+
844+
/** Amount of milliseconds to delay the closing sequence. */
845+
_mouseLeaveHideDelay: number;
846+
827847
/** Whether interactions on the page should close the tooltip */
828848
private _closeOnInteraction: boolean = false;
829849

@@ -885,6 +905,7 @@ export abstract class _TooltipComponentBase implements OnDestroy {
885905
clearTimeout(this._showTimeoutId);
886906
clearTimeout(this._hideTimeoutId);
887907
this._onHide.complete();
908+
this._triggerElement = null!;
888909
}
889910

890911
_animationStart() {
@@ -923,6 +944,12 @@ export abstract class _TooltipComponentBase implements OnDestroy {
923944
this._changeDetectorRef.markForCheck();
924945
}
925946

947+
_handleMouseLeave({relatedTarget}: MouseEvent) {
948+
if (!relatedTarget || !this._triggerElement.contains(relatedTarget as Node)) {
949+
this.hide(this._mouseLeaveHideDelay);
950+
}
951+
}
952+
926953
/**
927954
* Callback for when the timeout in this.show() gets completed.
928955
* This method is only needed by the mdc-tooltip, and so it is only implemented
@@ -946,6 +973,7 @@ export abstract class _TooltipComponentBase implements OnDestroy {
946973
// Forces the element to have a layout in IE and Edge. This fixes issues where the element
947974
// won't be rendered if the animations are disabled or there is no web animations polyfill.
948975
'[style.zoom]': '_visibility === "visible" ? 1 : null',
976+
'(mouseleave)': '_handleMouseLeave($event)',
949977
'aria-hidden': 'true',
950978
},
951979
})

tools/public_api_guard/material/tooltip.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,14 @@ export abstract class _TooltipComponentBase implements OnDestroy {
178178
// (undocumented)
179179
_animationStart(): void;
180180
_handleBodyInteraction(): void;
181+
// (undocumented)
182+
_handleMouseLeave({ relatedTarget }: MouseEvent): void;
181183
hide(delay: number): void;
182184
_hideTimeoutId: number | undefined;
183185
isVisible(): boolean;
184186
_markForCheck(): void;
185187
message: string;
188+
_mouseLeaveHideDelay: number;
186189
// (undocumented)
187190
ngOnDestroy(): void;
188191
protected _onShow(): void;
@@ -191,6 +194,7 @@ export abstract class _TooltipComponentBase implements OnDestroy {
191194
tooltipClass: string | string[] | Set<string> | {
192195
[key: string]: any;
193196
};
197+
_triggerElement: HTMLElement;
194198
_visibility: TooltipVisibility;
195199
// (undocumented)
196200
static ɵdir: i0.ɵɵDirectiveDeclaration<_TooltipComponentBase, never, never, {}, {}, never>;

0 commit comments

Comments
 (0)