diff --git a/src/popover/popover.spec.ts b/src/popover/popover.spec.ts index 86a6146085..3459eec53e 100644 --- a/src/popover/popover.spec.ts +++ b/src/popover/popover.spec.ts @@ -633,6 +633,27 @@ describe('ngb-popover', () => { }); describe('visibility', () => { + it('should stay open if the popover is hovered before the closeDelay times out', fakeAsync(() => { + const fixture = createTestComponent( + `
`, + ); + const directive = fixture.debugElement.query(By.directive(NgbPopover)); + + triggerEvent(directive, 'mouseenter'); + tick(); + expect(getWindow(fixture.nativeElement)).toBeTruthy(); + + triggerEvent(directive, 'mouseleave'); + tick(100); + triggerEvent(getWindow(fixture.nativeElement), 'mouseenter'); + tick(300); + expect(getWindow(fixture.nativeElement)).toBeTruthy(); + + triggerEvent(getWindow(fixture.nativeElement), 'mouseleave'); + tick(300); + expect(getWindow(fixture.nativeElement)).toBeFalsy(); + })); + it('should emit events when showing and hiding popover', () => { const fixture = createTestComponent( `
`, diff --git a/src/popover/popover.ts b/src/popover/popover.ts index a8655b0b84..787440dd6e 100644 --- a/src/popover/popover.ts +++ b/src/popover/popover.ts @@ -6,6 +6,7 @@ import { Directive, ElementRef, EventEmitter, + HostListener, inject, Input, NgZone, @@ -28,7 +29,7 @@ import { isString } from '../util/util'; import { NgbPopoverConfig } from './popover-config'; import { addPopperOffset } from '../util/positioning-util'; -import { Subscription } from 'rxjs'; +import { Subject, Subscription } from 'rxjs'; let nextId = 0; @@ -67,10 +68,22 @@ export class NgbPopoverWindow { @Input() id: string; @Input() popoverClass: string; @Input() context: any; + @Input() readonly mouseEnter: Subject; + @Input() readonly mouseLeave: Subject; isTitleTemplate() { return this.title instanceof TemplateRef; } + + @HostListener('mouseenter') + onMouseEnter() { + this.mouseEnter?.next(); + } + + @HostListener('mouseleave') + onMouseLeave() { + this.mouseLeave?.next(); + } } /** @@ -215,6 +228,9 @@ export class NgbPopover implements OnInit, OnDestroy, OnChanges { private _positioning = ngbPositioning(); private _zoneSubscription: Subscription; + private _mouseEnterTooltip = new Subject(); + private _mouseLeaveTooltip = new Subject(); + /** * Opens the popover. * @@ -235,6 +251,8 @@ export class NgbPopover implements OnInit, OnDestroy, OnChanges { this._windowRef.setInput('context', context ?? this.popoverContext); this._windowRef.setInput('popoverClass', this.popoverClass); this._windowRef.setInput('id', this._ngbPopoverWindowId); + this._windowRef.setInput('mouseEnter', this._mouseEnterTooltip); + this._windowRef.setInput('mouseLeave', this._mouseLeaveTooltip); this._getPositionTargetElement().setAttribute('aria-describedby', this._ngbPopoverWindowId); @@ -326,6 +344,8 @@ export class NgbPopover implements OnInit, OnDestroy, OnChanges { this.close.bind(this), +this.openDelay, +this.closeDelay, + this._mouseEnterTooltip, + this._mouseLeaveTooltip, ); } @@ -341,6 +361,8 @@ export class NgbPopover implements OnInit, OnDestroy, OnChanges { ngOnDestroy() { this.close(false); + this._mouseEnterTooltip.complete(); + this._mouseLeaveTooltip.complete(); // This check is needed as it might happen that ngOnDestroy is called before ngOnInit // under certain conditions, see: https://github.com/ng-bootstrap/ng-bootstrap/issues/2199 this._unregisterListenersFn?.(); diff --git a/src/tooltip/tooltip.spec.ts b/src/tooltip/tooltip.spec.ts index d3d46b0d0e..5d1ffd7786 100644 --- a/src/tooltip/tooltip.spec.ts +++ b/src/tooltip/tooltip.spec.ts @@ -646,6 +646,27 @@ describe('ngb-tooltip', () => { }); describe('visibility', () => { + it('should stay open if the tooltip is hovered before the closeDelay times out', fakeAsync(() => { + const fixture = createTestComponent( + `
`, + ); + const directive = fixture.debugElement.query(By.directive(NgbTooltip)); + + triggerEvent(directive, 'mouseenter'); + tick(); + expect(getWindow(fixture.nativeElement)).toBeTruthy(); + + triggerEvent(directive, 'mouseleave'); + tick(100); + triggerEvent(getWindow(fixture.nativeElement), 'mouseenter'); + tick(300); + expect(getWindow(fixture.nativeElement)).toBeTruthy(); + + triggerEvent(getWindow(fixture.nativeElement), 'mouseleave'); + tick(300); + expect(getWindow(fixture.nativeElement)).toBeFalsy(); + })); + it('should emit events when showing and hiding tooltip', () => { const fixture = createTestComponent( `
`, diff --git a/src/tooltip/tooltip.ts b/src/tooltip/tooltip.ts index f197658b16..48867f9025 100644 --- a/src/tooltip/tooltip.ts +++ b/src/tooltip/tooltip.ts @@ -6,6 +6,7 @@ import { Directive, ElementRef, EventEmitter, + HostListener, inject, Input, NgZone, @@ -26,7 +27,7 @@ import { PopupService } from '../util/popup'; import { isString } from '../util/util'; import { NgbTooltipConfig } from './tooltip-config'; -import { Subscription } from 'rxjs'; +import { Subject, Subscription } from 'rxjs'; import { addPopperOffset } from '../util/positioning-util'; let nextId = 0; @@ -54,6 +55,18 @@ export class NgbTooltipWindow { @Input() animation: boolean; @Input() id: string; @Input() tooltipClass: string; + @Input() readonly mouseEnter: Subject; + @Input() readonly mouseLeave: Subject; + + @HostListener('mouseenter') + onMouseEnter() { + this.mouseEnter?.next(); + } + + @HostListener('mouseleave') + onMouseLeave() { + this.mouseLeave?.next(); + } } /** @@ -183,6 +196,9 @@ export class NgbTooltip implements OnInit, OnDestroy, OnChanges { private _positioning = ngbPositioning(); private _zoneSubscription: Subscription; + private _mouseEnterTooltip = new Subject(); + private _mouseLeaveTooltip = new Subject(); + /** * The string content or a `TemplateRef` for the content to be displayed in the tooltip. * @@ -217,6 +233,8 @@ export class NgbTooltip implements OnInit, OnDestroy, OnChanges { this._windowRef.setInput('animation', this.animation); this._windowRef.setInput('tooltipClass', this.tooltipClass); this._windowRef.setInput('id', this._ngbTooltipWindowId); + this._windowRef.setInput('mouseEnter', this._mouseEnterTooltip); + this._windowRef.setInput('mouseLeave', this._mouseLeaveTooltip); this._getPositionTargetElement().setAttribute('aria-describedby', this._ngbTooltipWindowId); @@ -314,6 +332,8 @@ export class NgbTooltip implements OnInit, OnDestroy, OnChanges { this.close.bind(this), +this.openDelay, +this.closeDelay, + this._mouseEnterTooltip, + this._mouseLeaveTooltip, ); } @@ -325,6 +345,8 @@ export class NgbTooltip implements OnInit, OnDestroy, OnChanges { ngOnDestroy() { this.close(false); + this._mouseEnterTooltip.complete(); + this._mouseLeaveTooltip.complete(); // This check is needed as it might happen that ngOnDestroy is called before ngOnInit // under certain conditions, see: https://github.com/ng-bootstrap/ng-bootstrap/issues/2199 this._unregisterListenersFn?.(); diff --git a/src/util/triggers.ts b/src/util/triggers.ts index 3027a14bbd..80de0cbeb9 100644 --- a/src/util/triggers.ts +++ b/src/util/triggers.ts @@ -1,3 +1,5 @@ +import { Observable, EMPTY } from 'rxjs'; + const ALIASES = { hover: ['mouseenter', 'mouseleave'], focus: ['focusin', 'focusout'], @@ -36,6 +38,8 @@ export function listenToTriggers( closeFn: () => void, openDelayMs = 0, closeDelayMs = 0, + enterPopover: Observable = EMPTY, + leavePopover: Observable = EMPTY, ) { const parsedTriggers = parseTriggers(triggers); @@ -76,6 +80,17 @@ export function listenToTriggers( withDelay(() => activeOpenTriggers.size === 0 && closeFn(), closeDelayMs); }); } + + if (openTrigger === 'mouseenter' && closeTrigger === 'mouseleave' && closeDelayMs > 0) { + enterPopover.subscribe(() => { + activeOpenTriggers.add(openTrigger); + clearTimeout(timeout); + }); + leavePopover.subscribe(() => { + activeOpenTriggers.delete(openTrigger); + withDelay(() => activeOpenTriggers.size === 0 && closeFn(), closeDelayMs); + }); + } } return () => cleanupFns.forEach((cleanupFn) => cleanupFn());