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