Skip to content

Commit 409246f

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 469b790 commit 409246f

File tree

8 files changed

+171
-45
lines changed

8 files changed

+171
-45
lines changed

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

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
class="mdc-tooltip mdc-tooltip--shown mat-mdc-tooltip"
33
[ngClass]="tooltipClass"
44
[class.mdc-tooltip--multiline]="_isMultiline"
5+
[style.margin.px]="_gap"
56
[@state]="_visibility"
67
(@state.start)="_animationStart()"
78
(@state.done)="_animationDone($event)">

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

-5
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,4 @@
66
.mat-mdc-tooltip {
77
// We don't use MDC's positioning so this has to be static.
88
position: static;
9-
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;
149
}

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

+66
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,71 @@ 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+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
933+
fixture.detectChanges();
934+
tick(0);
935+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
936+
937+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseleave');
938+
fixture.detectChanges();
939+
tick(0);
940+
expect(tooltipDirective._isTooltipVisible()).toBe(false);
941+
}));
942+
943+
it('should not hide on mouseleave if the pointer goes from the trigger to the tooltip', fakeAsync(() => {
944+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
945+
fixture.detectChanges();
946+
tick(0);
947+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
948+
949+
const tooltipElement = overlayContainerElement.querySelector(
950+
'.mat-mdc-tooltip',
951+
) as HTMLElement;
952+
const event = createMouseEvent('mouseleave');
953+
Object.defineProperty(event, 'relatedTarget', {value: tooltipElement});
954+
955+
dispatchEvent(fixture.componentInstance.button.nativeElement, event);
956+
fixture.detectChanges();
957+
tick(0);
958+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
959+
}));
960+
961+
it('should hide on mouseleave on the tooltip', fakeAsync(() => {
962+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
963+
fixture.detectChanges();
964+
tick(0);
965+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
966+
967+
const tooltipElement = overlayContainerElement.querySelector(
968+
'.mat-mdc-tooltip',
969+
) as HTMLElement;
970+
dispatchMouseEvent(tooltipElement, 'mouseleave');
971+
fixture.detectChanges();
972+
tick(0);
973+
expect(tooltipDirective._isTooltipVisible()).toBe(false);
974+
}));
975+
976+
it('should not hide on mouseleave if the pointer goes from the tooltip to the trigger', fakeAsync(() => {
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+
const event = createMouseEvent('mouseleave');
986+
Object.defineProperty(event, 'relatedTarget', {
987+
value: fixture.componentInstance.button.nativeElement,
988+
});
989+
990+
dispatchEvent(tooltipElement, event);
991+
fixture.detectChanges();
992+
tick(0);
993+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
994+
}));
929995
});
930996

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

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

+6-19
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {DOCUMENT} from '@angular/common';
2222
import {Platform} from '@angular/cdk/platform';
2323
import {AriaDescriber, FocusMonitor} from '@angular/cdk/a11y';
2424
import {Directionality} from '@angular/cdk/bidi';
25-
import {ConnectedPosition, Overlay, ScrollDispatcher} from '@angular/cdk/overlay';
25+
import {Overlay, ScrollDispatcher} from '@angular/cdk/overlay';
2626
import {
2727
MatTooltipDefaultOptions,
2828
MAT_TOOLTIP_DEFAULT_OPTIONS,
@@ -87,23 +87,6 @@ export class MatTooltip extends _MatTooltipBase<TooltipComponent> {
8787
);
8888
this._viewportMargin = numbers.MIN_VIEWPORT_TOOLTIP_THRESHOLD;
8989
}
90-
91-
protected override _addOffset(position: ConnectedPosition): ConnectedPosition {
92-
const offset = numbers.UNBOUNDED_ANCHOR_GAP;
93-
const isLtr = !this._dir || this._dir.value == 'ltr';
94-
95-
if (position.originY === 'top') {
96-
position.offsetY = -offset;
97-
} else if (position.originY === 'bottom') {
98-
position.offsetY = offset;
99-
} else if (position.originX === 'start') {
100-
position.offsetX = isLtr ? -offset : offset;
101-
} else if (position.originX === 'end') {
102-
position.offsetX = isLtr ? offset : -offset;
103-
}
104-
105-
return position;
106-
}
10790
}
10891

10992
/**
@@ -121,12 +104,16 @@ export class MatTooltip extends _MatTooltipBase<TooltipComponent> {
121104
// Forces the element to have a layout in IE and Edge. This fixes issues where the element
122105
// won't be rendered if the animations are disabled or there is no web animations polyfill.
123106
'[style.zoom]': '_visibility === "visible" ? 1 : null',
107+
'(mouseleave)': '_handleMouseLeave($event)',
124108
'aria-hidden': 'true',
125109
},
126110
})
127111
export class TooltipComponent extends _TooltipComponentBase {
128112
/* Whether the tooltip text overflows to multiple lines */
129-
_isMultiline: boolean = false;
113+
_isMultiline = false;
114+
115+
/** Gap between the tooltip and the trigger. */
116+
_gap = numbers.UNBOUNDED_ANCHOR_GAP;
130117

131118
constructor(changeDetectorRef: ChangeDetectorRef, private _elementRef: ElementRef) {
132119
super(changeDetectorRef);

src/material/tooltip/tooltip.scss

-7
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

+60
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,65 @@ 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+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
910+
fixture.detectChanges();
911+
tick(0);
912+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
913+
914+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseleave');
915+
fixture.detectChanges();
916+
tick(0);
917+
expect(tooltipDirective._isTooltipVisible()).toBe(false);
918+
}));
919+
920+
it('should not hide on mouseleave if the pointer goes from the trigger to the tooltip', fakeAsync(() => {
921+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
922+
fixture.detectChanges();
923+
tick(0);
924+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
925+
926+
const tooltipElement = overlayContainerElement.querySelector('.mat-tooltip') as HTMLElement;
927+
const event = createMouseEvent('mouseleave');
928+
Object.defineProperty(event, 'relatedTarget', {value: tooltipElement});
929+
930+
dispatchEvent(fixture.componentInstance.button.nativeElement, event);
931+
fixture.detectChanges();
932+
tick(0);
933+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
934+
}));
935+
936+
it('should hide on mouseleave on the tooltip', fakeAsync(() => {
937+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
938+
fixture.detectChanges();
939+
tick(0);
940+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
941+
942+
const tooltipElement = overlayContainerElement.querySelector('.mat-tooltip') as HTMLElement;
943+
dispatchMouseEvent(tooltipElement, 'mouseleave');
944+
fixture.detectChanges();
945+
tick(0);
946+
expect(tooltipDirective._isTooltipVisible()).toBe(false);
947+
}));
948+
949+
it('should not hide on mouseleave if the pointer goes from the tooltip to the trigger', fakeAsync(() => {
950+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
951+
fixture.detectChanges();
952+
tick(0);
953+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
954+
955+
const tooltipElement = overlayContainerElement.querySelector('.mat-tooltip') as HTMLElement;
956+
const event = createMouseEvent('mouseleave');
957+
Object.defineProperty(event, 'relatedTarget', {
958+
value: fixture.componentInstance.button.nativeElement,
959+
});
960+
961+
dispatchEvent(tooltipElement, event);
962+
fixture.detectChanges();
963+
tick(0);
964+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
965+
}));
906966
});
907967

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

src/material/tooltip/tooltip.ts

+34-12
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,
@@ -207,6 +206,10 @@ export abstract class _MatTooltipBase<T extends _TooltipComponentBase>
207206
}
208207
set hideDelay(value: NumberInput) {
209208
this._hideDelay = coerceNumberProperty(value);
209+
210+
if (this._tooltipInstance) {
211+
this._tooltipInstance._mouseLeaveHideDelay = this._hideDelay;
212+
}
210213
}
211214
private _hideDelay = this._defaultOptions.hideDelay;
212215

@@ -376,14 +379,16 @@ export abstract class _MatTooltipBase<T extends _TooltipComponentBase>
376379
this._detach();
377380
this._portal =
378381
this._portal || new ComponentPortal(this._tooltipComponent, this._viewContainerRef);
379-
this._tooltipInstance = overlayRef.attach(this._portal).instance;
380-
this._tooltipInstance
382+
const instance = (this._tooltipInstance = overlayRef.attach(this._portal).instance);
383+
instance._triggerElement = this._elementRef.nativeElement;
384+
instance._mouseLeaveHideDelay = this._hideDelay;
385+
instance
381386
.afterHidden()
382387
.pipe(takeUntil(this._destroyed))
383388
.subscribe(() => this._detach());
384389
this._setTooltipClass(this._tooltipClass);
385390
this._updateTooltipMessage();
386-
this._tooltipInstance!.show(delay);
391+
instance.show(delay);
387392
}
388393

389394
/** Hides the tooltip after the delay in ms, defaults to tooltip-delay-hide or 0ms if no input */
@@ -483,16 +488,11 @@ export abstract class _MatTooltipBase<T extends _TooltipComponentBase>
483488
const overlay = this._getOverlayPosition();
484489

485490
position.withPositions([
486-
this._addOffset({...origin.main, ...overlay.main}),
487-
this._addOffset({...origin.fallback, ...overlay.fallback}),
491+
{...origin.main, ...overlay.main},
492+
{...origin.fallback, ...overlay.fallback},
488493
]);
489494
}
490495

491-
/** Adds the configured offset to a position. Used as a hook for child classes. */
492-
protected _addOffset(position: ConnectedPosition): ConnectedPosition {
493-
return position;
494-
}
495-
496496
/**
497497
* Returns the origin position and a fallback position based on the user's position preference.
498498
* The fallback position is the inverse of the origin (e.g. `'below' -> 'above'`).
@@ -687,7 +687,15 @@ export abstract class _MatTooltipBase<T extends _TooltipComponentBase>
687687
const exitListeners: (readonly [string, EventListenerOrEventListenerObject])[] = [];
688688
if (this._platformSupportsMouseEvents()) {
689689
exitListeners.push(
690-
['mouseleave', () => this.hide()],
690+
[
691+
'mouseleave',
692+
event => {
693+
const newTarget = (event as MouseEvent).relatedTarget as Node | null;
694+
if (!newTarget || !this._overlayRef?.overlayElement.contains(newTarget)) {
695+
this.hide();
696+
}
697+
},
698+
],
691699
['wheel', event => this._wheelListener(event as WheelEvent)],
692700
);
693701
} else if (this.touchGestures !== 'off') {
@@ -824,6 +832,12 @@ export abstract class _TooltipComponentBase implements OnDestroy {
824832
/** Property watched by the animation framework to show or hide the tooltip */
825833
_visibility: TooltipVisibility = 'initial';
826834

835+
/** Element that caused the tooltip to open. */
836+
_triggerElement: HTMLElement;
837+
838+
/** Amount of milliseconds to delay the closing sequence. */
839+
_mouseLeaveHideDelay: number;
840+
827841
/** Whether interactions on the page should close the tooltip */
828842
private _closeOnInteraction: boolean = false;
829843

@@ -885,6 +899,7 @@ export abstract class _TooltipComponentBase implements OnDestroy {
885899
clearTimeout(this._showTimeoutId);
886900
clearTimeout(this._hideTimeoutId);
887901
this._onHide.complete();
902+
this._triggerElement = null!;
888903
}
889904

890905
_animationStart() {
@@ -923,6 +938,12 @@ export abstract class _TooltipComponentBase implements OnDestroy {
923938
this._changeDetectorRef.markForCheck();
924939
}
925940

941+
_handleMouseLeave({relatedTarget}: MouseEvent) {
942+
if (!relatedTarget || !this._triggerElement.contains(relatedTarget as Node)) {
943+
this.hide(this._mouseLeaveHideDelay);
944+
}
945+
}
946+
926947
/**
927948
* Callback for when the timeout in this.show() gets completed.
928949
* This method is only needed by the mdc-tooltip, and so it is only implemented
@@ -946,6 +967,7 @@ export abstract class _TooltipComponentBase implements OnDestroy {
946967
// Forces the element to have a layout in IE and Edge. This fixes issues where the element
947968
// won't be rendered if the animations are disabled or there is no web animations polyfill.
948969
'[style.zoom]': '_visibility === "visible" ? 1 : null',
970+
'(mouseleave)': '_handleMouseLeave($event)',
949971
'aria-hidden': 'true',
950972
},
951973
})

tools/public_api_guard/material/tooltip.md

+4-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import { BreakpointObserver } from '@angular/cdk/layout';
1313
import { BreakpointState } from '@angular/cdk/layout';
1414
import { ChangeDetectorRef } from '@angular/core';
1515
import { ComponentType } from '@angular/cdk/portal';
16-
import { ConnectedPosition } from '@angular/cdk/overlay';
1716
import { Directionality } from '@angular/cdk/bidi';
1817
import { ElementRef } from '@angular/core';
1918
import { FocusMonitor } from '@angular/cdk/a11y';
@@ -78,7 +77,6 @@ export const matTooltipAnimations: {
7877
// @public (undocumented)
7978
export abstract class _MatTooltipBase<T extends _TooltipComponentBase> implements OnDestroy, AfterViewInit {
8079
constructor(_overlay: Overlay, _elementRef: ElementRef<HTMLElement>, _scrollDispatcher: ScrollDispatcher, _viewContainerRef: ViewContainerRef, _ngZone: NgZone, _platform: Platform, _ariaDescriber: AriaDescriber, _focusMonitor: FocusMonitor, scrollStrategy: any, _dir: Directionality, _defaultOptions: MatTooltipDefaultOptions, _document: any);
81-
protected _addOffset(position: ConnectedPosition): ConnectedPosition;
8280
// (undocumented)
8381
protected readonly _cssClassPrefix: string;
8482
// (undocumented)
@@ -178,11 +176,14 @@ export abstract class _TooltipComponentBase implements OnDestroy {
178176
// (undocumented)
179177
_animationStart(): void;
180178
_handleBodyInteraction(): void;
179+
// (undocumented)
180+
_handleMouseLeave({ relatedTarget }: MouseEvent): void;
181181
hide(delay: number): void;
182182
_hideTimeoutId: number | undefined;
183183
isVisible(): boolean;
184184
_markForCheck(): void;
185185
message: string;
186+
_mouseLeaveHideDelay: number;
186187
// (undocumented)
187188
ngOnDestroy(): void;
188189
protected _onShow(): void;
@@ -191,6 +192,7 @@ export abstract class _TooltipComponentBase implements OnDestroy {
191192
tooltipClass: string | string[] | Set<string> | {
192193
[key: string]: any;
193194
};
195+
_triggerElement: HTMLElement;
194196
_visibility: TooltipVisibility;
195197
// (undocumented)
196198
static ɵdir: i0.ɵɵDirectiveDeclaration<_TooltipComponentBase, never, never, {}, {}, never>;

0 commit comments

Comments
 (0)