Skip to content

Commit 03cd0e1

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

File tree

7 files changed

+227
-44
lines changed

7 files changed

+227
-44
lines changed

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

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

66
.mat-mdc-tooltip {
7+
$margin: 8px;
8+
79
// We don't use MDC's positioning so this has to be static.
810
position: static;
911

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;
12+
// The margin here is used to create a "bridge" for the pointer so that it can move directly
13+
// from the trigger to the toolip without closing. It's also used as an alternative to the
14+
// overlay's offset.
15+
.mat-mdc-tooltip-panel-above & {
16+
margin-bottom: $margin;
17+
}
18+
19+
.mat-mdc-tooltip-panel-below & {
20+
margin-top: $margin;
21+
}
22+
23+
.mat-mdc-tooltip-panel-left & {
24+
margin-right: $margin;
25+
}
26+
27+
.mat-mdc-tooltip-panel-right & {
28+
margin-left: $margin;
29+
}
1430
}

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: 3 additions & 19 deletions
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,13 @@ 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;
130114

131115
constructor(changeDetectorRef: ChangeDetectorRef, private _elementRef: ElementRef) {
132116
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', () => {

0 commit comments

Comments
 (0)