Skip to content

Commit 0dfc490

Browse files
authored
fix(material/tooltip): don't hide when pointer moves to tooltip (#24475)
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 856c016 commit 0dfc490

File tree

7 files changed

+301
-24
lines changed

7 files changed

+301
-24
lines changed

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

+17-6
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,22 @@
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+
}
21+
}
22+
23+
.mat-mdc-tooltip-panel-non-interactive {
24+
pointer-events: none;
1425
}

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

+115
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,
@@ -237,6 +238,35 @@ describe('MDC-based MatTooltip', () => {
237238
expect(tooltipDirective._getOverlayPosition().fallback.overlayX).toBe('end');
238239
}));
239240

241+
it('should be able to disable tooltip interactivity', fakeAsync(() => {
242+
TestBed.resetTestingModule()
243+
.configureTestingModule({
244+
imports: [MatTooltipModule, OverlayModule, NoopAnimationsModule],
245+
declarations: [TooltipDemoWithoutPositionBinding],
246+
providers: [
247+
{
248+
provide: MAT_TOOLTIP_DEFAULT_OPTIONS,
249+
useValue: {disableTooltipInteractivity: true},
250+
},
251+
],
252+
})
253+
.compileComponents();
254+
255+
const newFixture = TestBed.createComponent(TooltipDemoWithoutPositionBinding);
256+
newFixture.detectChanges();
257+
tooltipDirective = newFixture.debugElement
258+
.query(By.css('button'))!
259+
.injector.get<MatTooltip>(MatTooltip);
260+
261+
tooltipDirective.show();
262+
newFixture.detectChanges();
263+
tick();
264+
265+
expect(tooltipDirective._overlayRef?.overlayElement.classList).toContain(
266+
'mat-mdc-tooltip-panel-non-interactive',
267+
);
268+
}));
269+
240270
it('should set a css class on the overlay panel element', fakeAsync(() => {
241271
tooltipDirective.show();
242272
fixture.detectChanges();
@@ -926,6 +956,91 @@ describe('MDC-based MatTooltip', () => {
926956
expect(tooltipElement.classList).toContain('mdc-tooltip--multiline');
927957
expect(tooltipDirective._tooltipInstance?._isMultiline).toBeTrue();
928958
}));
959+
960+
it('should hide on mouseleave on the trigger', fakeAsync(() => {
961+
// We don't bind mouse events on mobile devices.
962+
if (platform.IOS || platform.ANDROID) {
963+
return;
964+
}
965+
966+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
967+
fixture.detectChanges();
968+
tick(0);
969+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
970+
971+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseleave');
972+
fixture.detectChanges();
973+
tick(0);
974+
expect(tooltipDirective._isTooltipVisible()).toBe(false);
975+
}));
976+
977+
it('should not hide on mouseleave if the pointer goes from the trigger to the tooltip', fakeAsync(() => {
978+
// We don't bind mouse events on mobile devices.
979+
if (platform.IOS || platform.ANDROID) {
980+
return;
981+
}
982+
983+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
984+
fixture.detectChanges();
985+
tick(0);
986+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
987+
988+
const tooltipElement = overlayContainerElement.querySelector(
989+
'.mat-mdc-tooltip',
990+
) as HTMLElement;
991+
const event = createMouseEvent('mouseleave');
992+
Object.defineProperty(event, 'relatedTarget', {value: tooltipElement});
993+
994+
dispatchEvent(fixture.componentInstance.button.nativeElement, event);
995+
fixture.detectChanges();
996+
tick(0);
997+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
998+
}));
999+
1000+
it('should hide on mouseleave on the tooltip', fakeAsync(() => {
1001+
// We don't bind mouse events on mobile devices.
1002+
if (platform.IOS || platform.ANDROID) {
1003+
return;
1004+
}
1005+
1006+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
1007+
fixture.detectChanges();
1008+
tick(0);
1009+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
1010+
1011+
const tooltipElement = overlayContainerElement.querySelector(
1012+
'.mat-mdc-tooltip',
1013+
) as HTMLElement;
1014+
dispatchMouseEvent(tooltipElement, 'mouseleave');
1015+
fixture.detectChanges();
1016+
tick(0);
1017+
expect(tooltipDirective._isTooltipVisible()).toBe(false);
1018+
}));
1019+
1020+
it('should not hide on mouseleave if the pointer goes from the tooltip to the trigger', fakeAsync(() => {
1021+
// We don't bind mouse events on mobile devices.
1022+
if (platform.IOS || platform.ANDROID) {
1023+
return;
1024+
}
1025+
1026+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
1027+
fixture.detectChanges();
1028+
tick(0);
1029+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
1030+
1031+
const tooltipElement = overlayContainerElement.querySelector(
1032+
'.mat-mdc-tooltip',
1033+
) as HTMLElement;
1034+
const event = createMouseEvent('mouseleave');
1035+
Object.defineProperty(event, 'relatedTarget', {
1036+
value: fixture.componentInstance.button.nativeElement,
1037+
});
1038+
1039+
dispatchEvent(tooltipElement, event);
1040+
fixture.detectChanges();
1041+
tick(0);
1042+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
1043+
}));
9291044
});
9301045

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

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

+2-1
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

+4-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;
@@ -34,3 +27,7 @@ $handset-margin: 24px;
3427
padding-left: $handset-horizontal-padding;
3528
padding-right: $handset-horizontal-padding;
3629
}
30+
31+
.mat-tooltip-panel-non-interactive {
32+
pointer-events: none;
33+
}

src/material/tooltip/tooltip.spec.ts

+109
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,
@@ -235,6 +236,35 @@ describe('MatTooltip', () => {
235236
expect(tooltipDirective._getOverlayPosition().fallback.overlayX).toBe('end');
236237
}));
237238

239+
it('should be able to disable tooltip interactivity', fakeAsync(() => {
240+
TestBed.resetTestingModule()
241+
.configureTestingModule({
242+
imports: [MatTooltipModule, OverlayModule, NoopAnimationsModule],
243+
declarations: [TooltipDemoWithoutPositionBinding],
244+
providers: [
245+
{
246+
provide: MAT_TOOLTIP_DEFAULT_OPTIONS,
247+
useValue: {disableTooltipInteractivity: true},
248+
},
249+
],
250+
})
251+
.compileComponents();
252+
253+
const newFixture = TestBed.createComponent(TooltipDemoWithoutPositionBinding);
254+
newFixture.detectChanges();
255+
tooltipDirective = newFixture.debugElement
256+
.query(By.css('button'))!
257+
.injector.get<MatTooltip>(MatTooltip);
258+
259+
tooltipDirective.show();
260+
newFixture.detectChanges();
261+
tick();
262+
263+
expect(tooltipDirective._overlayRef?.overlayElement.classList).toContain(
264+
'mat-tooltip-panel-non-interactive',
265+
);
266+
}));
267+
238268
it('should set a css class on the overlay panel element', fakeAsync(() => {
239269
tooltipDirective.show();
240270
fixture.detectChanges();
@@ -903,6 +933,85 @@ describe('MatTooltip', () => {
903933
// throw if we have any timers by the end of the test.
904934
fixture.destroy();
905935
}));
936+
937+
it('should hide on mouseleave on the trigger', fakeAsync(() => {
938+
// We don't bind mouse events on mobile devices.
939+
if (platform.IOS || platform.ANDROID) {
940+
return;
941+
}
942+
943+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
944+
fixture.detectChanges();
945+
tick(0);
946+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
947+
948+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseleave');
949+
fixture.detectChanges();
950+
tick(0);
951+
expect(tooltipDirective._isTooltipVisible()).toBe(false);
952+
}));
953+
954+
it('should not hide on mouseleave if the pointer goes from the trigger to the tooltip', fakeAsync(() => {
955+
// We don't bind mouse events on mobile devices.
956+
if (platform.IOS || platform.ANDROID) {
957+
return;
958+
}
959+
960+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
961+
fixture.detectChanges();
962+
tick(0);
963+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
964+
965+
const tooltipElement = overlayContainerElement.querySelector('.mat-tooltip') as HTMLElement;
966+
const event = createMouseEvent('mouseleave');
967+
Object.defineProperty(event, 'relatedTarget', {value: tooltipElement});
968+
969+
dispatchEvent(fixture.componentInstance.button.nativeElement, event);
970+
fixture.detectChanges();
971+
tick(0);
972+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
973+
}));
974+
975+
it('should hide on mouseleave on the tooltip', fakeAsync(() => {
976+
// We don't bind mouse events on mobile devices.
977+
if (platform.IOS || platform.ANDROID) {
978+
return;
979+
}
980+
981+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
982+
fixture.detectChanges();
983+
tick(0);
984+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
985+
986+
const tooltipElement = overlayContainerElement.querySelector('.mat-tooltip') as HTMLElement;
987+
dispatchMouseEvent(tooltipElement, 'mouseleave');
988+
fixture.detectChanges();
989+
tick(0);
990+
expect(tooltipDirective._isTooltipVisible()).toBe(false);
991+
}));
992+
993+
it('should not hide on mouseleave if the pointer goes from the tooltip to the trigger', fakeAsync(() => {
994+
// We don't bind mouse events on mobile devices.
995+
if (platform.IOS || platform.ANDROID) {
996+
return;
997+
}
998+
999+
dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter');
1000+
fixture.detectChanges();
1001+
tick(0);
1002+
expect(tooltipDirective._isTooltipVisible()).toBe(true);
1003+
1004+
const tooltipElement = overlayContainerElement.querySelector('.mat-tooltip') 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+
}));
9061015
});
9071016

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

0 commit comments

Comments
 (0)