From 4a44835de6ae7f7cf985e83d9b2389ecd267b992 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 25 Feb 2022 20:04:26 +0100 Subject: [PATCH] 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. --- .../mdc-tooltip/tooltip.scss | 23 +++- .../mdc-tooltip/tooltip.spec.ts | 115 ++++++++++++++++++ .../mdc-tooltip/tooltip.ts | 3 +- src/material/tooltip/tooltip.scss | 11 +- src/material/tooltip/tooltip.spec.ts | 109 +++++++++++++++++ src/material/tooltip/tooltip.ts | 54 +++++++- tools/public_api_guard/material/tooltip.md | 10 +- 7 files changed, 301 insertions(+), 24 deletions(-) diff --git a/src/material-experimental/mdc-tooltip/tooltip.scss b/src/material-experimental/mdc-tooltip/tooltip.scss index 10c9cb2f2712..07c0a9af2dba 100644 --- a/src/material-experimental/mdc-tooltip/tooltip.scss +++ b/src/material-experimental/mdc-tooltip/tooltip.scss @@ -4,11 +4,22 @@ @include tooltip.core-styles($query: structure); .mat-mdc-tooltip { - // We don't use MDC's positioning so this has to be static. - position: static; + // We don't use MDC's positioning so this has to be relative. + position: relative; - // The overlay reference updates the pointer-events style property directly on the HTMLElement - // depending on the state of the overlay. For tooltips the overlay panel should never enable - // pointer events. To overwrite the inline CSS from the overlay reference `!important` is needed. - pointer-events: none !important; + // Increases the area of the tooltip so the user's pointer can go from the trigger directly to it. + &::before { + $offset: -8px; + content: ''; + top: $offset; + right: $offset; + bottom: $offset; + left: $offset; + z-index: -1; + position: absolute; + } +} + +.mat-mdc-tooltip-panel-non-interactive { + pointer-events: none; } diff --git a/src/material-experimental/mdc-tooltip/tooltip.spec.ts b/src/material-experimental/mdc-tooltip/tooltip.spec.ts index 272e70e165ef..020a0f7674c0 100644 --- a/src/material-experimental/mdc-tooltip/tooltip.spec.ts +++ b/src/material-experimental/mdc-tooltip/tooltip.spec.ts @@ -7,6 +7,7 @@ import {Platform} from '@angular/cdk/platform'; import { createFakeEvent, createKeyboardEvent, + createMouseEvent, dispatchEvent, dispatchFakeEvent, dispatchKeyboardEvent, @@ -237,6 +238,35 @@ describe('MDC-based MatTooltip', () => { expect(tooltipDirective._getOverlayPosition().fallback.overlayX).toBe('end'); })); + it('should be able to disable tooltip interactivity', fakeAsync(() => { + TestBed.resetTestingModule() + .configureTestingModule({ + imports: [MatTooltipModule, OverlayModule, NoopAnimationsModule], + declarations: [TooltipDemoWithoutPositionBinding], + providers: [ + { + provide: MAT_TOOLTIP_DEFAULT_OPTIONS, + useValue: {disableTooltipInteractivity: true}, + }, + ], + }) + .compileComponents(); + + const newFixture = TestBed.createComponent(TooltipDemoWithoutPositionBinding); + newFixture.detectChanges(); + tooltipDirective = newFixture.debugElement + .query(By.css('button'))! + .injector.get(MatTooltip); + + tooltipDirective.show(); + newFixture.detectChanges(); + tick(); + + expect(tooltipDirective._overlayRef?.overlayElement.classList).toContain( + 'mat-mdc-tooltip-panel-non-interactive', + ); + })); + it('should set a css class on the overlay panel element', fakeAsync(() => { tooltipDirective.show(); fixture.detectChanges(); @@ -926,6 +956,91 @@ describe('MDC-based MatTooltip', () => { expect(tooltipElement.classList).toContain('mdc-tooltip--multiline'); expect(tooltipDirective._tooltipInstance?._isMultiline).toBeTrue(); })); + + it('should hide on mouseleave on the trigger', fakeAsync(() => { + // We don't bind mouse events on mobile devices. + if (platform.IOS || platform.ANDROID) { + return; + } + + dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter'); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseleave'); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(false); + })); + + it('should not hide on mouseleave if the pointer goes from the trigger to the tooltip', fakeAsync(() => { + // We don't bind mouse events on mobile devices. + if (platform.IOS || platform.ANDROID) { + return; + } + + dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter'); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + const tooltipElement = overlayContainerElement.querySelector( + '.mat-mdc-tooltip', + ) as HTMLElement; + const event = createMouseEvent('mouseleave'); + Object.defineProperty(event, 'relatedTarget', {value: tooltipElement}); + + dispatchEvent(fixture.componentInstance.button.nativeElement, event); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + })); + + it('should hide on mouseleave on the tooltip', fakeAsync(() => { + // We don't bind mouse events on mobile devices. + if (platform.IOS || platform.ANDROID) { + return; + } + + dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter'); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + const tooltipElement = overlayContainerElement.querySelector( + '.mat-mdc-tooltip', + ) as HTMLElement; + dispatchMouseEvent(tooltipElement, 'mouseleave'); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(false); + })); + + it('should not hide on mouseleave if the pointer goes from the tooltip to the trigger', fakeAsync(() => { + // We don't bind mouse events on mobile devices. + if (platform.IOS || platform.ANDROID) { + return; + } + + dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter'); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + const tooltipElement = overlayContainerElement.querySelector( + '.mat-mdc-tooltip', + ) as HTMLElement; + const event = createMouseEvent('mouseleave'); + Object.defineProperty(event, 'relatedTarget', { + value: fixture.componentInstance.button.nativeElement, + }); + + dispatchEvent(tooltipElement, event); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + })); }); describe('fallback positions', () => { diff --git a/src/material-experimental/mdc-tooltip/tooltip.ts b/src/material-experimental/mdc-tooltip/tooltip.ts index 6ef44bda3af5..cc0a49ba4d42 100644 --- a/src/material-experimental/mdc-tooltip/tooltip.ts +++ b/src/material-experimental/mdc-tooltip/tooltip.ts @@ -121,12 +121,13 @@ export class MatTooltip extends _MatTooltipBase { // Forces the element to have a layout in IE and Edge. This fixes issues where the element // won't be rendered if the animations are disabled or there is no web animations polyfill. '[style.zoom]': '_visibility === "visible" ? 1 : null', + '(mouseleave)': '_handleMouseLeave($event)', 'aria-hidden': 'true', }, }) export class TooltipComponent extends _TooltipComponentBase { /* Whether the tooltip text overflows to multiple lines */ - _isMultiline: boolean = false; + _isMultiline = false; constructor(changeDetectorRef: ChangeDetectorRef, private _elementRef: ElementRef) { super(changeDetectorRef); diff --git a/src/material/tooltip/tooltip.scss b/src/material/tooltip/tooltip.scss index 84cce45ad333..1d4c9eeb4b64 100644 --- a/src/material/tooltip/tooltip.scss +++ b/src/material/tooltip/tooltip.scss @@ -7,13 +7,6 @@ $margin: 14px; $handset-horizontal-padding: 16px; $handset-margin: 24px; -.mat-tooltip-panel { - // The overlay reference updates the pointer-events style property directly on the HTMLElement - // depending on the state of the overlay. For tooltips the overlay panel should never enable - // pointer events. To overwrite the inline CSS from the overlay reference `!important` is needed. - pointer-events: none !important; -} - .mat-tooltip { color: white; border-radius: 4px; @@ -34,3 +27,7 @@ $handset-margin: 24px; padding-left: $handset-horizontal-padding; padding-right: $handset-horizontal-padding; } + +.mat-tooltip-panel-non-interactive { + pointer-events: none; +} diff --git a/src/material/tooltip/tooltip.spec.ts b/src/material/tooltip/tooltip.spec.ts index c8118c52de15..759f642475a3 100644 --- a/src/material/tooltip/tooltip.spec.ts +++ b/src/material/tooltip/tooltip.spec.ts @@ -7,6 +7,7 @@ import {Platform} from '@angular/cdk/platform'; import { createFakeEvent, createKeyboardEvent, + createMouseEvent, dispatchEvent, dispatchFakeEvent, dispatchKeyboardEvent, @@ -235,6 +236,35 @@ describe('MatTooltip', () => { expect(tooltipDirective._getOverlayPosition().fallback.overlayX).toBe('end'); })); + it('should be able to disable tooltip interactivity', fakeAsync(() => { + TestBed.resetTestingModule() + .configureTestingModule({ + imports: [MatTooltipModule, OverlayModule, NoopAnimationsModule], + declarations: [TooltipDemoWithoutPositionBinding], + providers: [ + { + provide: MAT_TOOLTIP_DEFAULT_OPTIONS, + useValue: {disableTooltipInteractivity: true}, + }, + ], + }) + .compileComponents(); + + const newFixture = TestBed.createComponent(TooltipDemoWithoutPositionBinding); + newFixture.detectChanges(); + tooltipDirective = newFixture.debugElement + .query(By.css('button'))! + .injector.get(MatTooltip); + + tooltipDirective.show(); + newFixture.detectChanges(); + tick(); + + expect(tooltipDirective._overlayRef?.overlayElement.classList).toContain( + 'mat-tooltip-panel-non-interactive', + ); + })); + it('should set a css class on the overlay panel element', fakeAsync(() => { tooltipDirective.show(); fixture.detectChanges(); @@ -903,6 +933,85 @@ describe('MatTooltip', () => { // throw if we have any timers by the end of the test. fixture.destroy(); })); + + it('should hide on mouseleave on the trigger', fakeAsync(() => { + // We don't bind mouse events on mobile devices. + if (platform.IOS || platform.ANDROID) { + return; + } + + dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter'); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseleave'); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(false); + })); + + it('should not hide on mouseleave if the pointer goes from the trigger to the tooltip', fakeAsync(() => { + // We don't bind mouse events on mobile devices. + if (platform.IOS || platform.ANDROID) { + return; + } + + dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter'); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + const tooltipElement = overlayContainerElement.querySelector('.mat-tooltip') as HTMLElement; + const event = createMouseEvent('mouseleave'); + Object.defineProperty(event, 'relatedTarget', {value: tooltipElement}); + + dispatchEvent(fixture.componentInstance.button.nativeElement, event); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + })); + + it('should hide on mouseleave on the tooltip', fakeAsync(() => { + // We don't bind mouse events on mobile devices. + if (platform.IOS || platform.ANDROID) { + return; + } + + dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter'); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + const tooltipElement = overlayContainerElement.querySelector('.mat-tooltip') as HTMLElement; + dispatchMouseEvent(tooltipElement, 'mouseleave'); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(false); + })); + + it('should not hide on mouseleave if the pointer goes from the tooltip to the trigger', fakeAsync(() => { + // We don't bind mouse events on mobile devices. + if (platform.IOS || platform.ANDROID) { + return; + } + + dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter'); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + const tooltipElement = overlayContainerElement.querySelector('.mat-tooltip') as HTMLElement; + const event = createMouseEvent('mouseleave'); + Object.defineProperty(event, 'relatedTarget', { + value: fixture.componentInstance.button.nativeElement, + }); + + dispatchEvent(tooltipElement, event); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + })); }); describe('fallback positions', () => { diff --git a/src/material/tooltip/tooltip.ts b/src/material/tooltip/tooltip.ts index 4e348d1e482e..4299911474c7 100644 --- a/src/material/tooltip/tooltip.ts +++ b/src/material/tooltip/tooltip.ts @@ -17,7 +17,6 @@ import { import {ESCAPE, hasModifierKey} from '@angular/cdk/keycodes'; import {BreakpointObserver, Breakpoints, BreakpointState} from '@angular/cdk/layout'; import { - ConnectedPosition, FlexibleConnectedPositionStrategy, HorizontalConnectionPos, OriginConnectionPosition, @@ -27,6 +26,7 @@ import { ScrollStrategy, VerticalConnectionPos, ConnectionPositionPair, + ConnectedPosition, } from '@angular/cdk/overlay'; import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform'; import {ComponentPortal, ComponentType} from '@angular/cdk/portal'; @@ -113,11 +113,23 @@ export const MAT_TOOLTIP_SCROLL_STRATEGY_FACTORY_PROVIDER = { /** Default `matTooltip` options that can be overridden. */ export interface MatTooltipDefaultOptions { + /** Default delay when the tooltip is shown. */ showDelay: number; + + /** Default delay when the tooltip is hidden. */ hideDelay: number; + + /** Default delay when hiding the tooltip on a touch device. */ touchendHideDelay: number; + + /** Default touch gesture handling for tooltips. */ touchGestures?: TooltipTouchGestures; + + /** Default position for tooltips. */ position?: TooltipPosition; + + /** Disables the ability for the user to interact with the tooltip element. */ + disableTooltipInteractivity?: boolean; } /** Injection token to be used to override the default options for `matTooltip`. */ @@ -207,6 +219,10 @@ export abstract class _MatTooltipBase } set hideDelay(value: NumberInput) { this._hideDelay = coerceNumberProperty(value); + + if (this._tooltipInstance) { + this._tooltipInstance._mouseLeaveHideDelay = this._hideDelay; + } } private _hideDelay = this._defaultOptions.hideDelay; @@ -376,14 +392,16 @@ export abstract class _MatTooltipBase this._detach(); this._portal = this._portal || new ComponentPortal(this._tooltipComponent, this._viewContainerRef); - this._tooltipInstance = overlayRef.attach(this._portal).instance; - this._tooltipInstance + const instance = (this._tooltipInstance = overlayRef.attach(this._portal).instance); + instance._triggerElement = this._elementRef.nativeElement; + instance._mouseLeaveHideDelay = this._hideDelay; + instance .afterHidden() .pipe(takeUntil(this._destroyed)) .subscribe(() => this._detach()); this._setTooltipClass(this._tooltipClass); this._updateTooltipMessage(); - this._tooltipInstance!.show(delay); + instance.show(delay); } /** Hides the tooltip after the delay in ms, defaults to tooltip-delay-hide or 0ms if no input */ @@ -464,6 +482,10 @@ export abstract class _MatTooltipBase } }); + if (this._defaultOptions?.disableTooltipInteractivity) { + this._overlayRef.addPanelClass(`${this._cssClassPrefix}-tooltip-panel-non-interactive`); + } + return this._overlayRef; } @@ -687,7 +709,15 @@ export abstract class _MatTooltipBase const exitListeners: (readonly [string, EventListenerOrEventListenerObject])[] = []; if (this._platformSupportsMouseEvents()) { exitListeners.push( - ['mouseleave', () => this.hide()], + [ + 'mouseleave', + event => { + const newTarget = (event as MouseEvent).relatedTarget as Node | null; + if (!newTarget || !this._overlayRef?.overlayElement.contains(newTarget)) { + this.hide(); + } + }, + ], ['wheel', event => this._wheelListener(event as WheelEvent)], ); } else if (this.touchGestures !== 'off') { @@ -824,6 +854,12 @@ export abstract class _TooltipComponentBase implements OnDestroy { /** Property watched by the animation framework to show or hide the tooltip */ _visibility: TooltipVisibility = 'initial'; + /** Element that caused the tooltip to open. */ + _triggerElement: HTMLElement; + + /** Amount of milliseconds to delay the closing sequence. */ + _mouseLeaveHideDelay: number; + /** Whether interactions on the page should close the tooltip */ private _closeOnInteraction: boolean = false; @@ -885,6 +921,7 @@ export abstract class _TooltipComponentBase implements OnDestroy { clearTimeout(this._showTimeoutId); clearTimeout(this._hideTimeoutId); this._onHide.complete(); + this._triggerElement = null!; } _animationStart() { @@ -923,6 +960,12 @@ export abstract class _TooltipComponentBase implements OnDestroy { this._changeDetectorRef.markForCheck(); } + _handleMouseLeave({relatedTarget}: MouseEvent) { + if (!relatedTarget || !this._triggerElement.contains(relatedTarget as Node)) { + this.hide(this._mouseLeaveHideDelay); + } + } + /** * Callback for when the timeout in this.show() gets completed. * This method is only needed by the mdc-tooltip, and so it is only implemented @@ -946,6 +989,7 @@ export abstract class _TooltipComponentBase implements OnDestroy { // Forces the element to have a layout in IE and Edge. This fixes issues where the element // won't be rendered if the animations are disabled or there is no web animations polyfill. '[style.zoom]': '_visibility === "visible" ? 1 : null', + '(mouseleave)': '_handleMouseLeave($event)', 'aria-hidden': 'true', }, }) diff --git a/tools/public_api_guard/material/tooltip.md b/tools/public_api_guard/material/tooltip.md index e00038c4656a..9b9dbcabe573 100644 --- a/tools/public_api_guard/material/tooltip.md +++ b/tools/public_api_guard/material/tooltip.md @@ -131,15 +131,11 @@ export abstract class _MatTooltipBase implement // @public export interface MatTooltipDefaultOptions { - // (undocumented) + disableTooltipInteractivity?: boolean; hideDelay: number; - // (undocumented) position?: TooltipPosition; - // (undocumented) showDelay: number; - // (undocumented) touchendHideDelay: number; - // (undocumented) touchGestures?: TooltipTouchGestures; } @@ -178,11 +174,14 @@ export abstract class _TooltipComponentBase implements OnDestroy { // (undocumented) _animationStart(): void; _handleBodyInteraction(): void; + // (undocumented) + _handleMouseLeave({ relatedTarget }: MouseEvent): void; hide(delay: number): void; _hideTimeoutId: number | undefined; isVisible(): boolean; _markForCheck(): void; message: string; + _mouseLeaveHideDelay: number; // (undocumented) ngOnDestroy(): void; protected _onShow(): void; @@ -191,6 +190,7 @@ export abstract class _TooltipComponentBase implements OnDestroy { tooltipClass: string | string[] | Set | { [key: string]: any; }; + _triggerElement: HTMLElement; _visibility: TooltipVisibility; // (undocumented) static ɵdir: i0.ɵɵDirectiveDeclaration<_TooltipComponentBase, never, never, {}, {}, never>;