Skip to content

Commit

Permalink
fix(tooltip,popover): keep tooltip/popover open when it is hovered
Browse files Browse the repository at this point in the history
When using a hover trigger and a close delay, tooltips/popovers will be kept open if the mouse leaves the trigger element and enters the tooltip/popover element before the close delay times out.

Fixes ng-bootstrap#4168
  • Loading branch information
daiscog committed Mar 25, 2024
1 parent b5d5feb commit eefd9b9
Show file tree
Hide file tree
Showing 5 changed files with 103 additions and 2 deletions.
21 changes: 21 additions & 0 deletions src/popover/popover.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<div ngbPopover="Great tip!" triggers="hover" [closeDelay]="200" style="margin-top: 110px;"></div>`,
);
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(
`<div ngbPopover="Great tip!" triggers="click" (shown)="shown()" (hidden)="hidden()"></div>`,
Expand Down
24 changes: 23 additions & 1 deletion src/popover/popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Directive,
ElementRef,
EventEmitter,
HostListener,
inject,
Input,
NgZone,
Expand All @@ -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;

Expand Down Expand Up @@ -67,10 +68,22 @@ export class NgbPopoverWindow {
@Input() id: string;
@Input() popoverClass: string;
@Input() context: any;
@Input() readonly mouseEnter: Subject<void>;
@Input() readonly mouseLeave: Subject<void>;

isTitleTemplate() {
return this.title instanceof TemplateRef;
}

@HostListener('mouseenter')
onMouseEnter() {
this.mouseEnter?.next();
}

@HostListener('mouseleave')
onMouseLeave() {
this.mouseLeave?.next();
}
}

/**
Expand Down Expand Up @@ -215,6 +228,9 @@ export class NgbPopover implements OnInit, OnDestroy, OnChanges {
private _positioning = ngbPositioning();
private _zoneSubscription: Subscription;

private _mouseEnterTooltip = new Subject<void>();
private _mouseLeaveTooltip = new Subject<void>();

/**
* Opens the popover.
*
Expand All @@ -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);

Expand Down Expand Up @@ -326,6 +344,8 @@ export class NgbPopover implements OnInit, OnDestroy, OnChanges {
this.close.bind(this),
+this.openDelay,
+this.closeDelay,
this._mouseEnterTooltip,
this._mouseLeaveTooltip,
);
}

Expand All @@ -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?.();
Expand Down
21 changes: 21 additions & 0 deletions src/tooltip/tooltip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<div ngbTooltip="Great tip!" triggers="hover" [closeDelay]="200" style="margin-top: 110px;"></div>`,
);
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(
`<div ngbTooltip="Great tip!" triggers="click" (shown)="shown()" (hidden)="hidden()"></div>`,
Expand Down
24 changes: 23 additions & 1 deletion src/tooltip/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Directive,
ElementRef,
EventEmitter,
HostListener,
inject,
Input,
NgZone,
Expand All @@ -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;
Expand Down Expand Up @@ -54,6 +55,18 @@ export class NgbTooltipWindow {
@Input() animation: boolean;
@Input() id: string;
@Input() tooltipClass: string;
@Input() readonly mouseEnter: Subject<void>;
@Input() readonly mouseLeave: Subject<void>;

@HostListener('mouseenter')
onMouseEnter() {
this.mouseEnter?.next();
}

@HostListener('mouseleave')
onMouseLeave() {
this.mouseLeave?.next();
}
}

/**
Expand Down Expand Up @@ -183,6 +196,9 @@ export class NgbTooltip implements OnInit, OnDestroy, OnChanges {
private _positioning = ngbPositioning();
private _zoneSubscription: Subscription;

private _mouseEnterTooltip = new Subject<void>();
private _mouseLeaveTooltip = new Subject<void>();

/**
* The string content or a `TemplateRef` for the content to be displayed in the tooltip.
*
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -314,6 +332,8 @@ export class NgbTooltip implements OnInit, OnDestroy, OnChanges {
this.close.bind(this),
+this.openDelay,
+this.closeDelay,
this._mouseEnterTooltip,
this._mouseLeaveTooltip,
);
}

Expand All @@ -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?.();
Expand Down
15 changes: 15 additions & 0 deletions src/util/triggers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Observable, EMPTY } from 'rxjs';

const ALIASES = {
hover: ['mouseenter', 'mouseleave'],
focus: ['focusin', 'focusout'],
Expand Down Expand Up @@ -36,6 +38,8 @@ export function listenToTriggers(
closeFn: () => void,
openDelayMs = 0,
closeDelayMs = 0,
enterPopover: Observable<void> = EMPTY,
leavePopover: Observable<void> = EMPTY,
) {
const parsedTriggers = parseTriggers(triggers);

Expand Down Expand Up @@ -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());
Expand Down

0 comments on commit eefd9b9

Please sign in to comment.