Skip to content

Commit

Permalink
feat(tooltip): add openDelay and closeDelay
Browse files Browse the repository at this point in the history
Closes #1052
  • Loading branch information
divdavem authored and maxokorokov committed Feb 25, 2019
1 parent 3039e11 commit 1b8abae
Show file tree
Hide file tree
Showing 8 changed files with 260 additions and 16 deletions.
15 changes: 15 additions & 0 deletions demo/src/app/components/tooltip/demos/delay/tooltip-delay.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<p>
When using non-manual triggers, you can control the delay to open and close the tooltip through the <code>openDelay</code> and
<code>closeDelay</code> properties. Note that the <code>autoClose</code> feature does not use the close delay, it closes the tooltip immediately.
</p>

<button type="button" class="btn btn-outline-secondary mr-2"
ngbTooltip="You see, I show up after 300ms and disappear after 500ms!"
[openDelay]="300" [closeDelay]="500">
Hover 300ms here
</button>
<button type="button" class="btn btn-outline-secondary mr-2"
ngbTooltip="You see, I show up after 1s and disappear after 2s!"
[openDelay]="1000" [closeDelay]="2000">
Hover 1s here
</button>
8 changes: 8 additions & 0 deletions demo/src/app/components/tooltip/demos/delay/tooltip-delay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {Component} from '@angular/core';

@Component({
selector: 'ngbd-tooltip-delay',
templateUrl: './tooltip-delay.html'
})
export class NgbdTooltipDelay {
}
8 changes: 8 additions & 0 deletions demo/src/app/components/tooltip/tooltip.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { NgbdTooltipBasic } from './demos/basic/tooltip-basic';
import { NgbdTooltipConfig } from './demos/config/tooltip-config';
import { NgbdTooltipContainer } from './demos/container/tooltip-container';
import { NgbdTooltipCustomclass } from './demos/customclass/tooltip-customclass';
import { NgbdTooltipDelay } from './demos/delay/tooltip-delay';
import { NgbdTooltipTplcontent } from './demos/tplcontent/tooltip-tplcontent';
import { NgbdTooltipTplwithcontext } from './demos/tplwithcontext/tooltip-tplwithcontext';
import { NgbdTooltipTriggers } from './demos/triggers/tooltip-triggers';
Expand All @@ -18,6 +19,7 @@ const DEMO_DIRECTIVES = [
NgbdTooltipBasic,
NgbdTooltipContainer,
NgbdTooltipCustomclass,
NgbdTooltipDelay,
NgbdTooltipTplcontent,
NgbdTooltipTriggers,
NgbdTooltipAutoclose,
Expand Down Expand Up @@ -56,6 +58,12 @@ const DEMOS = {
code: require('!!raw-loader!./demos/tplwithcontext/tooltip-tplwithcontext'),
markup: require('!!raw-loader!./demos/tplwithcontext/tooltip-tplwithcontext.html')
},
delay: {
title: 'Open and close delays',
type: NgbdTooltipDelay,
code: require('!!raw-loader!./demos/delay/tooltip-delay'),
markup: require('!!raw-loader!./demos/delay/tooltip-delay.html')
},
container: {
title: 'Append tooltip in the body',
type: NgbdTooltipContainer,
Expand Down
2 changes: 2 additions & 0 deletions src/tooltip/tooltip-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ describe('ngb-tooltip-config', () => {
expect(config.container).toBeUndefined();
expect(config.disableTooltip).toBe(false);
expect(config.tooltipClass).toBeUndefined();
expect(config.openDelay).toBe(0);
expect(config.closeDelay).toBe(0);
});
});
2 changes: 2 additions & 0 deletions src/tooltip/tooltip-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ export class NgbTooltipConfig {
container: string;
disableTooltip = false;
tooltipClass: string;
openDelay = 0;
closeDelay = 0;
}
14 changes: 12 additions & 2 deletions src/tooltip/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ export class NgbTooltip implements OnInit, OnDestroy {
* @since 3.2.0
*/
@Input() tooltipClass: string;
/**
* Delay (in ms) before opening the tooltip after a non-manual opening trigger.
*/
@Input() openDelay: number;
/**
* Delay (in ms) before closing the tooltip after a non-manual closing trigger.
*/
@Input() closeDelay: number;
/**
* Emits an event when the tooltip is shown
*/
Expand All @@ -114,6 +122,8 @@ export class NgbTooltip implements OnInit, OnDestroy {
this.container = config.container;
this.disableTooltip = config.disableTooltip;
this.tooltipClass = config.tooltipClass;
this.openDelay = config.openDelay;
this.closeDelay = config.closeDelay;
this._popupService = new PopupService<NgbTooltipWindow>(
NgbTooltipWindow, injector, viewContainerRef, _renderer, componentFactoryResolver);

Expand Down Expand Up @@ -197,8 +207,8 @@ export class NgbTooltip implements OnInit, OnDestroy {

ngOnInit() {
this._unregisterListenersFn = listenToTriggers(
this._renderer, this._elementRef.nativeElement, this.triggers, this.open.bind(this), this.close.bind(this),
this.toggle.bind(this));
this._renderer, this._elementRef.nativeElement, this.triggers, this.isOpen.bind(this), this.open.bind(this),
this.close.bind(this), +this.openDelay, +this.closeDelay);
}

ngOnDestroy() {
Expand Down
150 changes: 149 additions & 1 deletion src/util/triggers.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {parseTriggers} from './triggers';
import {fakeAsync, tick} from '@angular/core/testing';
import {Subject, Subscription, Observable} from 'rxjs';
import {parseTriggers, triggerDelay} from './triggers';

describe('triggers', () => {

Expand Down Expand Up @@ -85,4 +87,150 @@ describe('triggers', () => {
});

});

describe('triggerDelay', () => {
let subject$: Subject<boolean>;
let delayed$: Observable<boolean>;
let open: boolean;
let subscription: Subscription;
let spy: jasmine.Spy;

beforeEach(() => {
subject$ = new Subject();
spy = jasmine.createSpy('listener', (newValue) => open = newValue).and.callThrough();
delayed$ = subject$.asObservable().pipe(triggerDelay(5000, 1000, () => open));
subscription = delayed$.subscribe(spy);
});

afterEach(() => {
if (subscription) {
subscription.unsubscribe();
subscription = null;
}
});

it('delays open', fakeAsync(() => {
open = false;
subject$.next(true);
tick(4999);
expect(spy).not.toHaveBeenCalled();
tick(2);
expect(spy).toHaveBeenCalledWith(true);
tick(100000);
expect(spy.calls.count()).toBe(1);
}));

it('cancels open if it is already done through another way', fakeAsync(() => {
open = false;
subject$.next(true);
tick(4999);
expect(spy).not.toHaveBeenCalled();
open = true;
tick(2);
expect(spy).not.toHaveBeenCalled();
tick(100000);
expect(spy.calls.count()).toBe(0);
}));

it('delays close', fakeAsync(() => {
open = true;
subject$.next(false);
tick(999);
expect(spy).not.toHaveBeenCalled();
tick(2);
expect(spy).toHaveBeenCalledWith(false);
tick(100000);
expect(spy.calls.count()).toBe(1);
}));

it('cancels close if it is already done through another way', fakeAsync(() => {
open = true;
subject$.next(false);
tick(999);
expect(spy).not.toHaveBeenCalled();
open = false;
tick(2);
expect(spy).not.toHaveBeenCalled();
tick(100000);
expect(spy.calls.count()).toBe(0);
}));

it('ignores extra open during openDelay', fakeAsync(() => {
open = false;
subject$.next(true);
tick(200);
subject$.next(true);
tick(100);
subject$.next(true);
tick(200);
tick(4499);
expect(spy).not.toHaveBeenCalled();
tick(2);
expect(spy).toHaveBeenCalledWith(true);
tick(100000);
expect(spy.calls.count()).toBe(1);
}));

it('ignores extra close during closeDelay', fakeAsync(() => {
open = true;
subject$.next(false);
tick(200);
subject$.next(false);
tick(100);
subject$.next(false);
tick(200);
tick(499);
expect(spy).not.toHaveBeenCalled();
tick(2);
expect(spy).toHaveBeenCalledWith(false);
tick(100000);
expect(spy.calls.count()).toBe(1);
}));

it('cancels open when receiving close during openDelay', fakeAsync(() => {
open = false;
subject$.next(true);
tick(4999);
subject$.next(false);
tick(100000);
expect(spy).not.toHaveBeenCalled();
}));

it('cancels close when receiving open during closeDelay', fakeAsync(() => {
open = true;
subject$.next(false);
tick(999);
subject$.next(true);
tick(100000);
expect(spy).not.toHaveBeenCalled();
}));

it('closes during openDelay if opened through another way', fakeAsync(() => {
open = false;
subject$.next(true);
tick(4999);
open = true;
subject$.next(false);
tick(999);
expect(spy).not.toHaveBeenCalled();
tick(2);
expect(spy).toHaveBeenCalledWith(false);
tick(100000);
expect(spy.calls.count()).toBe(1);
}));

it('opens during closeDelay if closed through another way', fakeAsync(() => {
open = true;
subject$.next(false);
tick(999);
open = false;
subject$.next(true);
tick(4999);
expect(spy).not.toHaveBeenCalled();
tick(2);
expect(spy).toHaveBeenCalledWith(true);
tick(100000);
expect(spy.calls.count()).toBe(1);
}));
});
});
77 changes: 64 additions & 13 deletions src/util/triggers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import {Observable, merge} from 'rxjs';
import {share, filter, delay, map} from 'rxjs/operators';

export class Trigger {
constructor(public open: string, public close?: string) {
if (!close) {
Expand Down Expand Up @@ -38,24 +41,72 @@ export function parseTriggers(triggers: string, aliases = DEFAULT_ALIASES): Trig
return parsedTriggers;
}

const noopFn = () => {};
export function observeTriggers(renderer: any, nativeElement: any, triggers: Trigger[], isOpenedFn: () => boolean) {
return new Observable<boolean>(subscriber => {
const listeners = [];
const openFn = () => subscriber.next(true);
const closeFn = () => subscriber.next(false);
const toggleFn = () => subscriber.next(!isOpenedFn());

triggers.forEach((trigger: Trigger) => {
if (trigger.open === trigger.close) {
listeners.push(renderer.listen(nativeElement, trigger.open, toggleFn));
} else {
listeners.push(
renderer.listen(nativeElement, trigger.open, openFn),
renderer.listen(nativeElement, trigger.close, closeFn));
}
});

return () => { listeners.forEach(unsubscribeFn => unsubscribeFn()); };
});
}

const delayOrNoop = <T>(time: number) => time > 0 ? delay<T>(time) : (a: Observable<T>) => a;

export function triggerDelay(openDelay: number, closeDelay: number, isOpenedFn: () => boolean) {
return (input$: Observable<boolean>) => {
let pending = null;
const filteredInput$ = input$.pipe(
map(open => ({open})), filter(event => {
const currentlyOpen = isOpenedFn();
if (currentlyOpen !== event.open && (!pending || pending.open === currentlyOpen)) {
pending = event;
return true;
}
if (pending && pending.open !== event.open) {
pending = null;
}
return false;
}),
share());
const delayedOpen$ = filteredInput$.pipe(filter(event => event.open), delayOrNoop(openDelay));
const delayedClose$ = filteredInput$.pipe(filter(event => !event.open), delayOrNoop(closeDelay));
return merge(delayedOpen$, delayedClose$)
.pipe(
filter(event => {
if (event === pending) {
pending = null;
return event.open !== isOpenedFn();
}
return false;
}),
map(event => event.open));
};
}

export function listenToTriggers(renderer: any, nativeElement: any, triggers: string, openFn, closeFn, toggleFn) {
export function listenToTriggers(
renderer: any, nativeElement: any, triggers: string, isOpenedFn: () => boolean, openFn, closeFn, openDelay = 0,
closeDelay = 0) {
const parsedTriggers = parseTriggers(triggers);
const listeners = [];

if (parsedTriggers.length === 1 && parsedTriggers[0].isManual()) {
return noopFn;
return () => {};
}

parsedTriggers.forEach((trigger: Trigger) => {
if (trigger.open === trigger.close) {
listeners.push(renderer.listen(nativeElement, trigger.open, toggleFn));
} else {
listeners.push(
renderer.listen(nativeElement, trigger.open, openFn), renderer.listen(nativeElement, trigger.close, closeFn));
}
});
const subscription = observeTriggers(renderer, nativeElement, parsedTriggers, isOpenedFn)
.pipe(triggerDelay(openDelay, closeDelay, isOpenedFn))
.subscribe(open => (open ? openFn() : closeFn()));

return () => { listeners.forEach(unsubscribeFn => unsubscribeFn()); };
return () => subscription.unsubscribe();
}

0 comments on commit 1b8abae

Please sign in to comment.