diff --git a/demo/src/app/components/accordion/demos/accordion-demo.component.html b/demo/src/app/components/accordion/demos/accordion-demo.component.html
index de8061cc5e..6452db2b3e 100644
--- a/demo/src/app/components/accordion/demos/accordion-demo.component.html
+++ b/demo/src/app/components/accordion/demos/accordion-demo.component.html
@@ -32,7 +32,7 @@
I can have markup, too!
+ [ngClass]="{'glyphicon-chevron-down': group?._isOpen, 'glyphicon-chevron-right': !group?._isOpen}">
This is just some content to illustrate fancy headings.
diff --git a/demo/src/app/components/tooltip/demos/tooltip-demo.component.html b/demo/src/app/components/tooltip/demos/tooltip-demo.component.html
index a09e1ffced..09b24cedda 100644
--- a/demo/src/app/components/tooltip/demos/tooltip-demo.component.html
+++ b/demo/src/app/components/tooltip/demos/tooltip-demo.component.html
@@ -10,72 +10,97 @@
Pellentesque {{dynamicTooltipText}} ,
sit amet venenatis urna cursus eget nunc scelerisque viverra mauris, in
aliquam. Tincidunt lobortis feugiat vivamus at
- left eget
+ left eget
arcu dictum varius duis at consectetur lorem. Vitae elementum curabitur
- right
+ right
nunc sed velit dignissim sodales ut eu sem integer vitae. Turpis egestas
- bottom
+ bottom
pharetra convallis posuere morbi leo urna,
fading
at elementum eu, facilisis sed odio morbi quis commodo odio. In cursus
- delayed turpis massa tincidunt dui ut.
+ delayed
+ turpis massa tincidunt dui ut.
nunc sed velit dignissim sodales ut eu sem integer vitae. Turpis egestas
- I can even contain HTML. Check me out!
+ I can even contain HTML.
+ Check me out!
+ Say, {{dynamicTooltipText}} !
-
+
Tool tip custom content defined inside a template
- With context binding: {{model.text}}
+ With context binding: {{dynamicTooltip}}
- Or use a TemplateRef. Check me out!
+ Or use a TemplateRef.
+ Check me out!
Programatically show/hide tooltip
- Check me out!
- Show tooltip
- Hide tooltip
-
-
-
- I can have a custom class. Check me out!
+ Check me out!
+ Show tooltip
+ Hide tooltip
+
+
+
+
+
+
+
+
- I can triggered by the custom events. For example, by the click. Check me out
+ I can triggered by the custom events. For example, by the click.
+ Check
+ me out
- I can combine trigger events. Now I can be displayed by the "click" and "focus" events.
- Click or tab me.
+ I can combine trigger events. Now I can be displayed by the "click" and
+ "focus" events.
+ Click or tab me.
-
-
- And if I am in overflow: hidden container, then just tooltipAppendToBody me instead!
+
+ And if I am in overflow:
+ hidden container, then just
+ tooltipAppendToBody
+ me instead!
diff --git a/demo/src/app/components/tooltip/index.ts b/demo/src/app/components/tooltip/index.ts
index 5295d8ab8c..f52074a19f 100644
--- a/demo/src/app/components/tooltip/index.ts
+++ b/demo/src/app/components/tooltip/index.ts
@@ -15,7 +15,7 @@ import { TooltipModule } from 'ng2-bootstrap';
CommonModule,
FormsModule,
SharedModule,
- TooltipModule
+ TooltipModule.forRoot()
],
exports: [TooltipSectionComponent]
})
diff --git a/src/component-loader/component-loader.class.ts b/src/component-loader/component-loader.class.ts
new file mode 100644
index 0000000000..e37880ed8a
--- /dev/null
+++ b/src/component-loader/component-loader.class.ts
@@ -0,0 +1,237 @@
+// todo: add delay support
+// todo: merge events onShow, onShown, etc...
+// todo: add global positioning configuration?
+import {
+ NgZone, ViewContainerRef, ComponentFactoryResolver, Injector, Renderer,
+ ElementRef, ComponentRef, ComponentFactory, Type, TemplateRef, EventEmitter
+} from '@angular/core';
+import { ContentRef } from './content-ref.class';
+import { PositioningService, PositioningOptions } from '../positioning';
+import { listenToTriggers } from '../utils/triggers';
+
+export interface ListenOptions {
+ target?: ElementRef;
+ triggers?: string;
+ show?: Function;
+ hide?: Function;
+ toggle?: Function;
+}
+
+export class ComponentLoader {
+ public onBeforeShow: EventEmitter = new EventEmitter();
+ public onShown: EventEmitter = new EventEmitter();
+ public onBeforeHide: EventEmitter = new EventEmitter();
+ public onHidden: EventEmitter = new EventEmitter();
+
+ public instance: T;
+
+ private _componentFactory: ComponentFactory;
+ private _elementRef: ElementRef;
+ private _componentRef: ComponentRef;
+ private _zoneSubscription: any;
+ private _contentRef: ContentRef;
+ private _viewContainerRef: ViewContainerRef;
+ private _injector: Injector;
+ private _renderer: Renderer;
+ private _ngZone: NgZone;
+ private _componentFactoryResolver: ComponentFactoryResolver;
+ private _posService: PositioningService;
+
+ private _unregisterListenersFn: Function;
+
+ public get isShown(): boolean {
+ return !!this._componentRef;
+ };
+
+ /**
+ * Placement of a component. Accepts: "top", "bottom", "left", "right"
+ */
+ private attachment: string;
+
+ /**
+ * A selector specifying the element the popover should be appended to.
+ * Currently only supports "body".
+ */
+ private container: string | ElementRef | any;
+
+ /**
+ * Specifies events that should trigger. Supports a space separated list of
+ * event names.
+ */
+ private triggers: string;
+
+ /**
+ * Do not use this directly, it should be instanced via
+ * `ComponentLoadFactory.attach`
+ * @internal
+ * @param _viewContainerRef
+ * @param _elementRef
+ * @param _injector
+ * @param _renderer
+ * @param _componentFactoryResolver
+ * @param _ngZone
+ * @param _posService
+ */
+ // tslint:disable-next-line
+ public constructor(_viewContainerRef: ViewContainerRef, _renderer: Renderer,
+ _elementRef: ElementRef,
+ _injector: Injector, _componentFactoryResolver: ComponentFactoryResolver,
+ _ngZone: NgZone, _posService: PositioningService) {
+ this._ngZone = _ngZone;
+ this._injector = _injector;
+ this._renderer = _renderer;
+ this._elementRef = _elementRef;
+ this._posService = _posService;
+ this._viewContainerRef = _viewContainerRef;
+ this._componentFactoryResolver = _componentFactoryResolver;
+ }
+
+ public attach(compType: Type): ComponentLoader {
+ this._componentFactory = this._componentFactoryResolver
+ .resolveComponentFactory(compType);
+ return this;
+ }
+
+ // todo: add behaviour: to target element, `body`, custom element
+ public to(container?: string): ComponentLoader {
+ this.container = container || this.container;
+ return this;
+ }
+
+ public position(opts?: PositioningOptions): ComponentLoader {
+ this.attachment = opts.attachment || this.attachment;
+ this._elementRef = opts.target as ElementRef || this._elementRef;
+ return this;
+ }
+
+ public show(content?: string | TemplateRef, mixin?: any): ComponentRef {
+ this._subscribePositioning();
+
+ if (!this._componentRef) {
+ this.onBeforeShow.emit();
+ this._contentRef = this._getContentRef(content);
+ this._componentRef = this._viewContainerRef
+ .createComponent(this._componentFactory, 0, this._injector, this._contentRef.nodes);
+ this.instance = this._componentRef.instance;
+
+ Object.assign(this._componentRef.instance, mixin || {});
+
+ if (this.container === 'body' && typeof document !== 'undefined') {
+ document.querySelector(this.container as string)
+ .appendChild(this._componentRef.location.nativeElement);
+ }
+
+ // we need to manually invoke change detection since events registered
+ // via
+ // Renderer::listen() are not picked up by change detection with the
+ // OnPush strategy
+ this._componentRef.changeDetectorRef.markForCheck();
+ this.onShown.emit(this._componentRef.instance);
+ }
+ return this._componentRef;
+ }
+
+ public hide(): ComponentLoader {
+ if (this._componentRef) {
+ this.onBeforeHide.emit(this._componentRef.instance);
+ this._viewContainerRef.remove(this._viewContainerRef.indexOf(this._componentRef.hostView));
+ this._componentRef = null;
+
+ if (this._contentRef.viewRef) {
+ this._viewContainerRef.remove(this._viewContainerRef.indexOf(this._contentRef.viewRef));
+ this._contentRef = null;
+ }
+
+ this._componentRef = null;
+ this.onHidden.emit();
+ }
+ return this;
+ }
+
+ public toggle(): void {
+ if (this.isShown) {
+ this.hide();
+ return;
+ }
+
+ this.show();
+ }
+
+ public dispose(): void {
+ if (this.isShown) {
+ this.hide();
+ }
+
+ this._unsubscribePositioning();
+
+ if (this._unregisterListenersFn) {
+ this._unregisterListenersFn();
+ }
+ }
+
+ public listen(listenOpts: ListenOptions): ComponentLoader {
+ if (this._unregisterListenersFn) {
+ this._unregisterListenersFn();
+ }
+
+ this.triggers = listenOpts.triggers || this.triggers;
+
+ listenOpts.target = listenOpts.target || this._elementRef;
+ listenOpts.show = listenOpts.show || (() => this.show());
+ listenOpts.hide = listenOpts.hide || (() => this.hide());
+ listenOpts.toggle = listenOpts.toggle || (() => this.isShown
+ ? listenOpts.hide()
+ : listenOpts.show());
+
+ this._unregisterListenersFn = listenToTriggers(
+ this._renderer,
+ listenOpts.target.nativeElement,
+ this.triggers,
+ listenOpts.show,
+ listenOpts.hide,
+ listenOpts.toggle);
+
+ return this;
+ }
+
+ private _subscribePositioning(): void {
+ if (this._zoneSubscription) {
+ return;
+ }
+
+ this._zoneSubscription = this._ngZone
+ .onStable.subscribe(() => {
+ if (!this._componentRef) {
+ return;
+ }
+ this._posService.position({
+ element: this._componentRef.location,
+ target: this._elementRef,
+ attachment: this.attachment,
+ appendToBody: this.container === 'body'
+ });
+ });
+ }
+
+ private _unsubscribePositioning(): void {
+ if (!this._zoneSubscription) {
+ return;
+ }
+ this._zoneSubscription.unsubscribe();
+ this._zoneSubscription = null;
+ }
+
+ private _getContentRef(content: string | TemplateRef): ContentRef {
+ if (!content) {
+ return new ContentRef([]);
+ }
+
+ if (content instanceof TemplateRef) {
+ const viewRef = this._viewContainerRef
+ .createEmbeddedView>(content);
+ return new ContentRef([viewRef.rootNodes], viewRef);
+ }
+
+ return new ContentRef([[this._renderer.createText(null, `${content}`)]]);
+ }
+}
diff --git a/src/component-loader/component-loader.factory.ts b/src/component-loader/component-loader.factory.ts
new file mode 100644
index 0000000000..b6b939aa4e
--- /dev/null
+++ b/src/component-loader/component-loader.factory.ts
@@ -0,0 +1,34 @@
+import {
+ Injectable, NgZone, ViewContainerRef, ComponentFactoryResolver, Injector,
+ Renderer, ElementRef
+} from '@angular/core';
+import { ComponentLoader } from './component-loader.class';
+import { PositioningService } from '../positioning';
+
+@Injectable()
+export class ComponentLoaderFactory {
+ private _componentFactoryResolver: ComponentFactoryResolver;
+ private _ngZone: NgZone;
+ private _injector: Injector;
+ private _posService: PositioningService;
+
+ public constructor(componentFactoryResolver: ComponentFactoryResolver, ngZone: NgZone,
+ injector: Injector, posService: PositioningService) {
+ this._ngZone = ngZone;
+ this._injector = injector;
+ this._posService = posService;
+ this._componentFactoryResolver = componentFactoryResolver;
+ }
+
+ /**
+ *
+ * @param _elementRef
+ * @param _viewContainerRef
+ * @param _renderer
+ * @returns {ComponentLoader}
+ */
+ public createLoader(_elementRef: ElementRef, _viewContainerRef: ViewContainerRef, _renderer: Renderer) {
+ return new ComponentLoader(_viewContainerRef, _renderer, _elementRef,
+ this._injector, this._componentFactoryResolver, this._ngZone, this._posService);
+ }
+}
diff --git a/src/component-loader/content-ref.class.ts b/src/component-loader/content-ref.class.ts
new file mode 100644
index 0000000000..0256cf4166
--- /dev/null
+++ b/src/component-loader/content-ref.class.ts
@@ -0,0 +1,18 @@
+/**
+ * @copyright Valor Software
+ * @copyright Angular ng-bootstrap team
+ */
+
+import { ComponentRef, ViewRef } from '@angular/core';
+
+export class ContentRef {
+ public nodes: any[];
+ public viewRef?: ViewRef;
+ public componentRef?: ComponentRef;
+
+ public constructor( nodes: any[], viewRef?: ViewRef, componentRef?: ComponentRef) {
+ this.nodes = nodes;
+ this.viewRef = viewRef;
+ this.componentRef = componentRef;
+ }
+}
diff --git a/src/component-loader/index.ts b/src/component-loader/index.ts
new file mode 100644
index 0000000000..666a011dac
--- /dev/null
+++ b/src/component-loader/index.ts
@@ -0,0 +1,3 @@
+export { ComponentLoader } from './component-loader.class';
+export { ComponentLoaderFactory } from './component-loader.factory';
+export { ContentRef } from './content-ref.class';
diff --git a/src/popover/popover-config.ts b/src/popover/popover-config.ts
new file mode 100644
index 0000000000..237453d822
--- /dev/null
+++ b/src/popover/popover-config.ts
@@ -0,0 +1,14 @@
+import { Injectable } from '@angular/core';
+
+/**
+ * Configuration service for the Popover directive.
+ * You can inject this service, typically in your root component, and customize
+ * the values of its properties in order to provide default values for all the
+ * popovers used in the application.
+ */
+@Injectable()
+export class PopoverConfig {
+ public placement: string = 'top';
+ public triggers: string = 'click';
+ public container: string;
+}
diff --git a/src/popover/popover-container.component.ts b/src/popover/popover-container.component.ts
new file mode 100644
index 0000000000..78af73245e
--- /dev/null
+++ b/src/popover/popover-container.component.ts
@@ -0,0 +1,17 @@
+import { ChangeDetectionStrategy, Input, Component } from '@angular/core';
+
+@Component({
+ selector: 'popover-container',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ // tslint:disable-next-line
+ host: {'[class]': '"popover in popover-" + placement', role: 'tooltip'},
+ template: `
+
+{{title}}
+
+ `
+})
+export class PopoverContainerComponent {
+ @Input() public placement: string;
+ @Input() public title: string;
+}
diff --git a/src/popover/popover.directive.ts b/src/popover/popover.directive.ts
new file mode 100644
index 0000000000..34cefed629
--- /dev/null
+++ b/src/popover/popover.directive.ts
@@ -0,0 +1,121 @@
+import {
+ Directive, Input, Output, EventEmitter, OnInit, OnDestroy, Renderer,
+ ElementRef, TemplateRef, ViewContainerRef
+} from '@angular/core';
+import { PopoverConfig } from './popover-config';
+import { ComponentLoaderFactory, ComponentLoader } from '../component-loader';
+import { PopoverContainerComponent } from './popover-container.component';
+
+/**
+ * A lightweight, extensible directive for fancy popover creation.
+ */
+@Directive({selector: '[popover]', exportAs: 'bs-popover'})
+export class PopoverDirective implements OnInit, OnDestroy {
+ /**
+ * Content to be displayed as popover.
+ */
+ @Input() public popover: string | TemplateRef;
+ /**
+ * Title of a popover.
+ */
+ @Input() public popoverTitle: string;
+ /**
+ * Placement of a popover. Accepts: "top", "bottom", "left", "right"
+ */
+ @Input() public placement: 'top' | 'bottom' | 'left' | 'right';
+ /**
+ * Specifies events that should trigger. Supports a space separated list of
+ * event names.
+ */
+ @Input() public triggers: string;
+ /**
+ * A selector specifying the element the popover should be appended to.
+ * Currently only supports "body".
+ */
+ @Input() public container: string;
+
+ /**
+ * Returns whether or not the popover is currently being shown
+ */
+ @Input
+ public get isOpen(): boolean { return this._popover.isShown; }
+
+ public set isOpen(value: boolean) {
+ if (value) {this.show();} else {this.hide();}
+ }
+
+ /**
+ * Emits an event when the popover is shown
+ */
+ @Output() public onShown: EventEmitter;
+ /**
+ * Emits an event when the popover is hidden
+ */
+ @Output() public onHidden: EventEmitter;
+
+ private _popover: ComponentLoader;
+
+ public constructor(_elementRef: ElementRef,
+ _renderer: Renderer,
+ _viewContainerRef: ViewContainerRef,
+ _config: PopoverConfig,
+ cis: ComponentLoaderFactory) {
+ this._popover = cis
+ .createLoader(_elementRef, _viewContainerRef, _renderer);
+ Object.assign(this, _config);
+ this.onShown = this._popover.onShown;
+ this.onHidden = this._popover.onHidden;
+ }
+
+ /**
+ * Opens an element’s popover. This is considered a “manual” triggering of
+ * the popover.
+ */
+ public show(): void {
+ if (this._popover.isShown) {
+ return;
+ }
+
+ this._popover
+ .attach(PopoverContainerComponent)
+ .to(this.container)
+ .position({attachment: this.placement})
+ .show(this.popover, {
+ placement: this.placement,
+ title: this.popoverTitle
+ });
+ }
+
+ /**
+ * Closes an element’s popover. This is considered a “manual” triggering of
+ * the popover.
+ */
+ public hide(): void {
+ if (this._popover.isShown) {
+ this._popover.hide();
+ }
+ }
+
+ /**
+ * Toggles an element’s popover. This is considered a “manual” triggering of
+ * the popover.
+ */
+ public toggle(): void {
+ if (this._popover.isShown) {
+ return this.hide();
+ }
+
+ this.show();
+ }
+
+ public ngOnInit(): any {
+ this._popover.listen({
+ triggers: this.triggers,
+ show: () => this.show()
+ });
+ }
+
+ public ngOnDestroy(): any {
+ this._popover.dispose();
+ }
+}
diff --git a/src/popover/popover.module.ts b/src/popover/popover.module.ts
new file mode 100644
index 0000000000..a3fa137df8
--- /dev/null
+++ b/src/popover/popover.module.ts
@@ -0,0 +1,22 @@
+import { NgModule, ModuleWithProviders } from '@angular/core';
+import { PopoverConfig } from './popover-config';
+import { ComponentLoaderFactory } from '../component-loader';
+import { PositioningService } from '../positioning';
+import { PopoverDirective } from './popover.directive';
+import { PopoverContainerComponent } from './popover-container.component';
+
+export { PopoverConfig } from './popover-config';
+
+@NgModule({
+ declarations: [PopoverDirective, PopoverContainerComponent],
+ exports: [PopoverDirective],
+ entryComponents: [PopoverContainerComponent]
+})
+export class PopoverModule {
+ public static forRoot(): ModuleWithProviders {
+ return {
+ ngModule: PopoverModule,
+ providers: [PopoverConfig, ComponentLoaderFactory, PositioningService]
+ };
+ }
+}
diff --git a/src/popover/popover.spec.ts b/src/popover/popover.spec.ts
new file mode 100644
index 0000000000..f4484fb1fc
--- /dev/null
+++ b/src/popover/popover.spec.ts
@@ -0,0 +1,474 @@
+import {TestBed, ComponentFixture, inject} from '@angular/core/testing';
+import {createGenericTestComponent} from '../test/common';
+
+import {By} from '@angular/platform-browser';
+import {Component, ViewChild, ChangeDetectionStrategy, Injectable, OnDestroy} from '@angular/core';
+
+import {PopoverModule} from './popover.module';
+import {PopoverContainerComponent, PopoverDirective} from './popover';
+import {PopoverConfig} from './popover-config';
+
+@Injectable()
+class SpyService {
+ called = false;
+}
+
+const createTestComponent = (html: string) =>
+ createGenericTestComponent(html, TestComponent) as ComponentFixture;
+
+const createOnPushTestComponent =
+ (html: string) => >createGenericTestComponent(html, TestOnPushComponent);
+
+describe('ngb-popover-window', () => {
+ beforeEach(() => {
+ TestBed.configureTestingModule({declarations: [TestComponent], imports: [PopoverModule.forRoot()]});
+ });
+
+ it('should render popover on top by default', () => {
+ const fixture = TestBed.createComponent(PopoverContainerComponent);
+ fixture.componentInstance.title = 'Test title';
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement).toHaveCssClass('popover');
+ expect(fixture.nativeElement).toHaveCssClass('popover-top');
+ expect(fixture.nativeElement.getAttribute('role')).toBe('tooltip');
+ expect(fixture.nativeElement.querySelector('.popover-title').textContent).toBe('Test title');
+ });
+
+ it('should position popovers as requested', () => {
+ const fixture = TestBed.createComponent(PopoverContainerComponent);
+ fixture.componentInstance.placement = 'left';
+ fixture.detectChanges();
+ expect(fixture.nativeElement).toHaveCssClass('popover-left');
+ });
+});
+
+describe('ngb-popover', () => {
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ declarations: [TestComponent, TestOnPushComponent, DestroyableCmpt],
+ imports: [PopoverModule.forRoot()],
+ providers: [SpyService]
+ });
+ });
+
+ function getWindow(element) { return element.querySelector('ngb-popover-window'); }
+
+ describe('basic functionality', () => {
+
+ it('should open and close a popover - default settings and content as string', () => {
+ const fixture = createTestComponent(`
`);
+ const directive = fixture.debugElement.query(By.directive(PopoverDirective));
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ const windowEl = getWindow(fixture.nativeElement);
+
+ expect(windowEl).toHaveCssClass('popover');
+ expect(windowEl).toHaveCssClass('popover-top');
+ expect(windowEl.textContent.trim()).toBe('TitleGreat tip!');
+ expect(windowEl.getAttribute('role')).toBe('tooltip');
+ expect(windowEl.parentNode).toBe(fixture.nativeElement);
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).toBeNull();
+ });
+
+ it('should open and close a popover - default settings and content from a template', () => {
+ const fixture = createTestComponent(`
+ Hello, {{name}}!
+
`);
+ const directive = fixture.debugElement.query(By.directive(PopoverDirective));
+ const defaultConfig = new PopoverConfig();
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ const windowEl = getWindow(fixture.nativeElement);
+
+ expect(windowEl).toHaveCssClass('popover');
+ expect(windowEl).toHaveCssClass(`popover-${defaultConfig.placement}`);
+ expect(windowEl.textContent.trim()).toBe('TitleHello, World!');
+ expect(windowEl.getAttribute('role')).toBe('tooltip');
+ expect(windowEl.parentNode).toBe(fixture.nativeElement);
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).toBeNull();
+ });
+
+ it('should properly destroy TemplateRef content', () => {
+ const fixture = createTestComponent(`
+
+
`);
+ const directive = fixture.debugElement.query(By.directive(PopoverDirective));
+ const spyService = fixture.debugElement.injector.get(SpyService);
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).not.toBeNull();
+ expect(spyService.called).toBeFalsy();
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).toBeNull();
+ expect(spyService.called).toBeTruthy();
+ });
+
+ it('should allow re-opening previously closed popovers', () => {
+ const fixture = createTestComponent(`
`);
+ const directive = fixture.debugElement.query(By.directive(PopoverDirective));
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).not.toBeNull();
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).toBeNull();
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).not.toBeNull();
+ });
+
+ it('should not leave dangling popovers in the DOM', () => {
+ const fixture =
+ createTestComponent(`
`);
+ const directive = fixture.debugElement.query(By.directive(PopoverDirective));
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).not.toBeNull();
+
+ fixture.componentInstance.show = false;
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).toBeNull();
+ });
+
+ it('should properly cleanup popovers with manual triggers', () => {
+ const fixture = createTestComponent(`
+
+ `);
+ const directive = fixture.debugElement.query(By.directive(PopoverDirective));
+
+ directive.triggerEventHandler('mouseenter', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).not.toBeNull();
+
+ fixture.componentInstance.show = false;
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).toBeNull();
+ });
+ });
+
+
+ describe('positioning', () => {
+
+ it('should use requested position', () => {
+ const fixture = createTestComponent(`
`);
+ const directive = fixture.debugElement.query(By.directive(PopoverDirective));
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ const windowEl = getWindow(fixture.nativeElement);
+
+ expect(windowEl).toHaveCssClass('popover');
+ expect(windowEl).toHaveCssClass('popover-left');
+ expect(windowEl.textContent.trim()).toBe('Great tip!');
+ });
+
+ it('should properly position popovers when a component is using the OnPush strategy', () => {
+ const fixture = createOnPushTestComponent(`
`);
+ const directive = fixture.debugElement.query(By.directive(PopoverDirective));
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ const windowEl = getWindow(fixture.nativeElement);
+
+ expect(windowEl).toHaveCssClass('popover');
+ expect(windowEl).toHaveCssClass('popover-left');
+ expect(windowEl.textContent.trim()).toBe('Great tip!');
+ });
+ });
+
+ describe('container', () => {
+
+ it('should be appended to the element matching the selector passed to "container"', () => {
+ const selector = 'body';
+ const fixture = createTestComponent(`
`);
+ const directive = fixture.debugElement.query(By.directive(PopoverDirective));
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).toBeNull();
+ expect(getWindow(window.document.querySelector(selector))).not.toBeNull();
+ });
+
+ it('should properly destroy popovers when the "container" option is used', () => {
+ const selector = 'body';
+ const fixture =
+ createTestComponent(`
`);
+ const directive = fixture.debugElement.query(By.directive(PopoverDirective));
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+
+ expect(getWindow(document.querySelector(selector))).not.toBeNull();
+ fixture.componentRef.instance.show = false;
+ fixture.detectChanges();
+ expect(getWindow(document.querySelector(selector))).toBeNull();
+ });
+
+ });
+
+ describe('visibility', () => {
+ it('should emit events when showing and hiding popover', () => {
+ const fixture = createTestComponent(
+ `
`);
+ const directive = fixture.debugElement.query(By.directive(PopoverDirective));
+
+ let shownSpy = spyOn(fixture.componentInstance, 'shown');
+ let hiddenSpy = spyOn(fixture.componentInstance, 'hidden');
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).not.toBeNull();
+ expect(shownSpy).toHaveBeenCalled();
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).toBeNull();
+ expect(hiddenSpy).toHaveBeenCalled();
+ });
+
+ it('should not emit close event when already closed', () => {
+ const fixture = createTestComponent(
+ `
`);
+
+ let shownSpy = spyOn(fixture.componentInstance, 'shown');
+ let hiddenSpy = spyOn(fixture.componentInstance, 'hidden');
+
+ fixture.componentInstance.popover.open();
+ fixture.detectChanges();
+
+ fixture.componentInstance.popover.open();
+ fixture.detectChanges();
+
+ expect(getWindow(fixture.nativeElement)).not.toBeNull();
+ expect(shownSpy).toHaveBeenCalled();
+ expect(shownSpy.calls.count()).toEqual(1);
+ expect(hiddenSpy).not.toHaveBeenCalled();
+ });
+
+ it('should not emit open event when already opened', () => {
+ const fixture = createTestComponent(
+ `
`);
+
+ let shownSpy = spyOn(fixture.componentInstance, 'shown');
+ let hiddenSpy = spyOn(fixture.componentInstance, 'hidden');
+
+ fixture.componentInstance.popover.close();
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).toBeNull();
+ expect(shownSpy).not.toHaveBeenCalled();
+ expect(hiddenSpy).not.toHaveBeenCalled();
+ });
+
+ it('should report correct visibility', () => {
+ const fixture = createTestComponent(`
`);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.popover.isOpen()).toBeFalsy();
+
+ fixture.componentInstance.popover.open();
+ fixture.detectChanges();
+ expect(fixture.componentInstance.popover.isOpen()).toBeTruthy();
+
+ fixture.componentInstance.popover.close();
+ fixture.detectChanges();
+ expect(fixture.componentInstance.popover.isOpen()).toBeFalsy();
+ });
+ });
+
+ describe('triggers', () => {
+ beforeEach(() => {
+ TestBed.configureTestingModule({declarations: [TestComponent], imports: [PopoverModule.forRoot()]});
+ });
+
+ it('should support toggle triggers', () => {
+ const fixture = createTestComponent(`
`);
+ const directive = fixture.debugElement.query(By.directive(PopoverDirective));
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).not.toBeNull();
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).toBeNull();
+ });
+
+ it('should non-default toggle triggers', () => {
+ const fixture = createTestComponent(`
`);
+ const directive = fixture.debugElement.query(By.directive(PopoverDirective));
+
+ directive.triggerEventHandler('mouseenter', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).not.toBeNull();
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).toBeNull();
+ });
+
+ it('should support multiple triggers', () => {
+ const fixture = createTestComponent(`
`);
+ const directive = fixture.debugElement.query(By.directive(PopoverDirective));
+
+ directive.triggerEventHandler('mouseenter', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).not.toBeNull();
+
+ directive.triggerEventHandler('click', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).toBeNull();
+ });
+
+ it('should not use default for manual triggers', () => {
+ const fixture = createTestComponent(`
`);
+ const directive = fixture.debugElement.query(By.directive(PopoverDirective));
+
+ directive.triggerEventHandler('mouseenter', {});
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).toBeNull();
+ });
+
+ it('should allow toggling for manual triggers', () => {
+ const fixture = createTestComponent(`
+
+ T `);
+ const button = fixture.nativeElement.querySelector('button');
+
+ button.click();
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).not.toBeNull();
+
+ button.click();
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).toBeNull();
+ });
+
+ it('should allow open / close for manual triggers', () => {
+ const fixture = createTestComponent(`
+ O
+ C `);
+ const buttons = fixture.nativeElement.querySelectorAll('button');
+
+ buttons[0].click(); // open
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).not.toBeNull();
+
+ buttons[1].click(); // close
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).toBeNull();
+ });
+
+ it('should not throw when open called for manual triggers and open popover', () => {
+ const fixture = createTestComponent(`
+
+ O `);
+ const button = fixture.nativeElement.querySelector('button');
+
+ button.click(); // open
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).not.toBeNull();
+
+ button.click(); // open
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).not.toBeNull();
+ });
+
+ it('should not throw when closed called for manual triggers and closed popover', () => {
+ const fixture = createTestComponent(`
+
+ C `);
+ const button = fixture.nativeElement.querySelector('button');
+
+ button.click(); // close
+ fixture.detectChanges();
+ expect(getWindow(fixture.nativeElement)).toBeNull();
+ });
+ });
+
+ describe('Custom config', () => {
+ let config: PopoverConfig;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({imports: [PopoverModule.forRoot()]});
+ TestBed.overrideComponent(TestComponent, {set: {template: `
`}});
+ });
+
+ beforeEach(inject([PopoverConfig], (c: PopoverConfig) => {
+ config = c;
+ config.placement = 'bottom';
+ config.triggers = 'hover';
+ config.container = 'body';
+ }));
+
+ it('should initialize inputs with provided config', () => {
+ const fixture = TestBed.createComponent(TestComponent);
+ fixture.detectChanges();
+
+ const popover = fixture.componentInstance.popover;
+
+ expect(popover.placement).toBe(config.placement);
+ expect(popover.triggers).toBe(config.triggers);
+ expect(popover.container).toBe(config.container);
+ });
+ });
+
+ describe('Custom config as provider', () => {
+ let config = new PopoverConfig();
+ config.placement = 'bottom';
+ config.triggers = 'hover';
+
+ beforeEach(() => {
+ TestBed.configureTestingModule(
+ {imports: [PopoverModule.forRoot()], providers: [{provide: PopoverConfig, useValue: config}]});
+ });
+
+ it('should initialize inputs with provided config as provider', () => {
+ const fixture = createTestComponent(`
`);
+ const popover = fixture.componentInstance.popover;
+
+ expect(popover.placement).toBe(config.placement);
+ expect(popover.triggers).toBe(config.triggers);
+ });
+ });
+});
+
+@Component({selector: 'test-cmpt', template: ``})
+export class TestComponent {
+ name = 'World';
+ show = true;
+ title: string;
+ placement: string;
+
+ @ViewChild(PopoverDirective) popover: PopoverDirective;
+
+ shown() {}
+ hidden() {}
+}
+
+@Component({selector: 'test-onpush-cmpt', changeDetection: ChangeDetectionStrategy.OnPush, template: ``})
+export class TestOnPushComponent {
+}
+
+@Component({selector: 'destroyable-cmpt', template: 'Some content'})
+export class DestroyableCmpt implements OnDestroy {
+ constructor(private _spyService: SpyService) {}
+
+ ngOnDestroy(): void { this._spyService.called = true; }
+}
diff --git a/src/positioning/index.ts b/src/positioning/index.ts
new file mode 100644
index 0000000000..350318632b
--- /dev/null
+++ b/src/positioning/index.ts
@@ -0,0 +1,2 @@
+export { positionElements, Positioning } from './ng-positioning'
+export { PositioningService, PositioningOptions } from './positioning.service';
diff --git a/src/positioning/ng-positioning.ts b/src/positioning/ng-positioning.ts
new file mode 100644
index 0000000000..c9cd5dc442
--- /dev/null
+++ b/src/positioning/ng-positioning.ts
@@ -0,0 +1,158 @@
+/**
+ * @copyright Valor Software
+ * @copyright Angular ng-bootstrap team
+ */
+
+// previous version:
+// https://github.com/angular-ui/bootstrap/blob/07c31d0731f7cb068a1932b8e01d2312b796b4ec/src/position/position.js
+// tslint:disable
+export class Positioning {
+ public position(element: HTMLElement, round = true): ClientRect {
+ let elPosition: ClientRect;
+ let parentOffset: ClientRect = {width: 0, height: 0, top: 0, bottom: 0, left: 0, right: 0};
+
+ if (this.getStyle(element, 'position') === 'fixed') {
+ elPosition = element.getBoundingClientRect();
+ } else {
+ const offsetParentEl = this.offsetParent(element);
+
+ elPosition = this.offset(element, false);
+
+ if (offsetParentEl !== document.documentElement) {
+ parentOffset = this.offset(offsetParentEl, false);
+ }
+
+ parentOffset.top += offsetParentEl.clientTop;
+ parentOffset.left += offsetParentEl.clientLeft;
+ }
+
+ elPosition.top -= parentOffset.top;
+ elPosition.bottom -= parentOffset.top;
+ elPosition.left -= parentOffset.left;
+ elPosition.right -= parentOffset.left;
+
+ if (round) {
+ elPosition.top = Math.round(elPosition.top);
+ elPosition.bottom = Math.round(elPosition.bottom);
+ elPosition.left = Math.round(elPosition.left);
+ elPosition.right = Math.round(elPosition.right);
+ }
+
+ return elPosition;
+ }
+
+ public offset(element: HTMLElement, round = true): ClientRect {
+ const elBcr = element.getBoundingClientRect();
+ const viewportOffset = {
+ top: window.pageYOffset - document.documentElement.clientTop,
+ left: window.pageXOffset - document.documentElement.clientLeft
+ };
+
+ let elOffset = {
+ height: elBcr.height || element.offsetHeight,
+ width: elBcr.width || element.offsetWidth,
+ top: elBcr.top + viewportOffset.top,
+ bottom: elBcr.bottom + viewportOffset.top,
+ left: elBcr.left + viewportOffset.left,
+ right: elBcr.right + viewportOffset.left
+ };
+
+ if (round) {
+ elOffset.height = Math.round(elOffset.height);
+ elOffset.width = Math.round(elOffset.width);
+ elOffset.top = Math.round(elOffset.top);
+ elOffset.bottom = Math.round(elOffset.bottom);
+ elOffset.left = Math.round(elOffset.left);
+ elOffset.right = Math.round(elOffset.right);
+ }
+
+ return elOffset;
+ }
+
+ public positionElements(hostElement: HTMLElement, targetElement: HTMLElement, placement: string, appendToBody?: boolean):
+ ClientRect {
+ const hostElPosition = appendToBody ? this.offset(hostElement, false) : this.position(hostElement, false);
+ const shiftWidth: any = {
+ left: hostElPosition.left,
+ center: hostElPosition.left + hostElPosition.width / 2 - targetElement.offsetWidth / 2,
+ right: hostElPosition.left + hostElPosition.width
+ };
+ const shiftHeight: any = {
+ top: hostElPosition.top,
+ center: hostElPosition.top + hostElPosition.height / 2 - targetElement.offsetHeight / 2,
+ bottom: hostElPosition.top + hostElPosition.height
+ };
+ const targetElBCR = targetElement.getBoundingClientRect();
+ const placementPrimary = placement.split('-')[0] || 'top';
+ const placementSecondary = placement.split('-')[1] || 'center';
+
+ let targetElPosition: ClientRect = {
+ height: targetElBCR.height || targetElement.offsetHeight,
+ width: targetElBCR.width || targetElement.offsetWidth,
+ top: 0,
+ bottom: targetElBCR.height || targetElement.offsetHeight,
+ left: 0,
+ right: targetElBCR.width || targetElement.offsetWidth
+ };
+
+ switch (placementPrimary) {
+ case 'top':
+ targetElPosition.top = hostElPosition.top - targetElement.offsetHeight;
+ targetElPosition.bottom += hostElPosition.top - targetElement.offsetHeight;
+ targetElPosition.left = shiftWidth[placementSecondary];
+ targetElPosition.right += shiftWidth[placementSecondary];
+ break;
+ case 'bottom':
+ targetElPosition.top = shiftHeight[placementPrimary];
+ targetElPosition.bottom += shiftHeight[placementPrimary];
+ targetElPosition.left = shiftWidth[placementSecondary];
+ targetElPosition.right += shiftWidth[placementSecondary];
+ break;
+ case 'left':
+ targetElPosition.top = shiftHeight[placementSecondary];
+ targetElPosition.bottom += shiftHeight[placementSecondary];
+ targetElPosition.left = hostElPosition.left - targetElement.offsetWidth;
+ targetElPosition.right += hostElPosition.left - targetElement.offsetWidth;
+ break;
+ case 'right':
+ targetElPosition.top = shiftHeight[placementSecondary];
+ targetElPosition.bottom += shiftHeight[placementSecondary];
+ targetElPosition.left = shiftWidth[placementPrimary];
+ targetElPosition.right += shiftWidth[placementPrimary];
+ break;
+ }
+
+ targetElPosition.top = Math.round(targetElPosition.top);
+ targetElPosition.bottom = Math.round(targetElPosition.bottom);
+ targetElPosition.left = Math.round(targetElPosition.left);
+ targetElPosition.right = Math.round(targetElPosition.right);
+
+ return targetElPosition;
+ }
+
+ private getStyle(element: HTMLElement, prop: string): string { return (window.getComputedStyle(element) as any)[prop]; }
+
+ private isStaticPositioned(element: HTMLElement): boolean {
+ return (this.getStyle(element, 'position') || 'static') === 'static';
+ }
+
+ private offsetParent(element: HTMLElement): HTMLElement {
+ let offsetParentEl = element.offsetParent || document.documentElement;
+
+ while (offsetParentEl && offsetParentEl !== document.documentElement && this.isStaticPositioned(offsetParentEl)) {
+ offsetParentEl = offsetParentEl.offsetParent;
+ }
+
+ return offsetParentEl || document.documentElement;
+ }
+}
+
+const positionService = new Positioning();
+
+export function positionElements(
+ hostElement: HTMLElement, targetElement: HTMLElement, placement: string, appendToBody?: boolean): void {
+ const pos = positionService.positionElements(hostElement, targetElement, placement, appendToBody);
+
+ targetElement.style.top = `${pos.top}px`;
+ targetElement.style.left = `${pos.left}px`;
+}
diff --git a/src/positioning/positioning.service.ts b/src/positioning/positioning.service.ts
new file mode 100644
index 0000000000..c0c9edcd00
--- /dev/null
+++ b/src/positioning/positioning.service.ts
@@ -0,0 +1,58 @@
+import { Injectable, ElementRef } from '@angular/core';
+import { positionElements } from './ng-positioning';
+
+export interface PositioningOptions {
+ /** The DOM element, ElementRef, or a selector string of an element which will be moved */
+ element?: HTMLElement | ElementRef | string;
+
+ /** The DOM element, ElementRef, or a selector string of an element which the element will be attached to */
+ target?: HTMLElement | ElementRef | string;
+
+ /**
+ * A string of the form 'vert-attachment horiz-attachment' or 'placement'
+ * - placement can be "top", "bottom", "left", "right"
+ * not yet supported:
+ * - vert-attachment can be any of 'top', 'middle', 'bottom'
+ * - horiz-attachment can be any of 'left', 'center', 'right'
+ */
+ attachment?: string;
+
+ /** A string similar to `attachment`. The one difference is that, if it's not provided, `targetAttachment` will assume the mirror image of `attachment`. */
+ targetAttachment?: string;
+
+ /** A string of the form 'vert-offset horiz-offset'
+ * - vert-offset and horiz-offset can be of the form "20px" or "55%"
+ */
+ offset?: string;
+
+ /** A string similar to `offset`, but referring to the offset of the target */
+ targetOffset?: string;
+
+ /** If true component will be attached to body */
+ appendToBody?: boolean;
+}
+
+@Injectable()
+export class PositioningService {
+ public position(options: PositioningOptions): void {
+ const {element, target, attachment, appendToBody} = options;
+ positionElements(
+ this._getHtmlElement(target),
+ this._getHtmlElement(element),
+ attachment,
+ appendToBody);
+ }
+
+ private _getHtmlElement(element: HTMLElement | ElementRef | string): HTMLElement {
+ // it means that we got a selector
+ if (typeof element === 'string') {
+ return document.querySelector(element) as HTMLElement;
+ }
+
+ if (element instanceof ElementRef) {
+ return element.nativeElement;
+ }
+
+ return element as HTMLElement;
+ }
+}
diff --git a/src/spec/tooltip.directive.spec.ts b/src/spec/tooltip.directive.spec.ts
index 23825a838f..720c65683d 100644
--- a/src/spec/tooltip.directive.spec.ts
+++ b/src/spec/tooltip.directive.spec.ts
@@ -63,7 +63,7 @@ describe('Directives: Tooltips', () => {
it('tooltip should be displayed after specified delay', fakeAsync(() => {
const element: HTMLElement = fixture.debugElement.nativeElement;
const tooltipElement: any = element.querySelector('#test-tooltip1');
- context.delay = 1000;
+ context._delay = 1000;
fixture.detectChanges();
tooltipElement.focus();
tick(1100);
@@ -77,7 +77,7 @@ describe('Directives: Tooltips', () => {
tooltipElement.focus();
tooltipElement.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
fixture.detectChanges();
- tick(context.delay);
+ tick(context._delay);
expect(element.querySelector('.tooltip-inner')).not.toBeNull();
}));
@@ -85,7 +85,7 @@ describe('Directives: Tooltips', () => {
const element: Element = fixture.debugElement.nativeElement;
const showTooltipBtn: any = element.querySelector('#showTooltipBtn');
showTooltipBtn.click();
- tick(context.delay);
+ tick(context._delay);
fixture.detectChanges();
expect(element.querySelector('.tooltip-inner')).not.toBeNull();
}));
@@ -94,7 +94,7 @@ describe('Directives: Tooltips', () => {
const element: Element = fixture.debugElement.nativeElement;
const showTooltipBtn: any = element.querySelector('#hideTooltipBtn');
showTooltipBtn.click();
- tick(context.delay);
+ tick(context._delay);
fixture.detectChanges();
expect(element.querySelector('.tooltip-inner')).toBeNull();
}));
diff --git a/src/test/common.spec.ts b/src/test/common.spec.ts
new file mode 100644
index 0000000000..e1a914f19d
--- /dev/null
+++ b/src/test/common.spec.ts
@@ -0,0 +1,72 @@
+/**
+ * @copyright Valor Software
+ * @copyright Angular ng-bootstrap team
+ */
+import { getBrowser, isBrowser } from './common';
+
+const sampleAgents = {
+ ie9: 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 7.1; Trident/5.0)',
+ ie10: 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)',
+ ie11: 'Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; AS; rv:11.0) like Gecko',
+ firefox: 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.1',
+ edge: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246',
+ chrome: 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36',
+ safari: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_3) AppleWebKit/537.75.14 (KHTML, like Gecko) Version/7.0.3 Safari/7046A194A',
+ unknown: 'Something that wont match at all'
+};
+
+describe('test-tools', () => {
+
+ describe('getBrowser()', () => {
+
+ it('should detect browsers', () => {
+ expect(getBrowser(sampleAgents.ie11))
+ .toBe('ie11');
+ expect(getBrowser(sampleAgents.ie10))
+ .toBe('ie10');
+ expect(getBrowser(sampleAgents.ie9))
+ .toBe('ie9');
+ expect(getBrowser(sampleAgents.edge))
+ .toBe('edge');
+ expect(getBrowser(sampleAgents.chrome))
+ .toBe('chrome');
+ expect(getBrowser(sampleAgents.safari))
+ .toBe('safari');
+ expect(getBrowser(sampleAgents.firefox))
+ .toBe('firefox');
+ });
+
+ it('should crash for an unknown browser', () => {
+ expect(() => { getBrowser(sampleAgents.unknown); })
+ .toThrow();
+ });
+ });
+
+ describe('isBrowser()', () => {
+
+ it('should match browser to the current one', () => {
+ expect(isBrowser('ie9', sampleAgents.ie9))
+ .toBeTruthy();
+ expect(isBrowser('ie9', sampleAgents.ie10))
+ .toBeFalsy();
+ });
+
+ it('should match an array of browsers to the current one', () => {
+ expect(isBrowser(['ie10', 'ie11'], sampleAgents.ie9))
+ .toBeFalsy();
+ expect(isBrowser(['ie9', 'ie11'], sampleAgents.ie9))
+ .toBeTruthy();
+ });
+
+ it('should match all ie browsers as one', () => {
+ expect(isBrowser('ie', sampleAgents.ie9))
+ .toBeTruthy();
+ expect(isBrowser(['ie'], sampleAgents.ie10))
+ .toBeTruthy();
+ expect(isBrowser(['ie', 'edge'], sampleAgents.ie11))
+ .toBeTruthy();
+ expect(isBrowser('edge', sampleAgents.ie11))
+ .toBeFalsy();
+ });
+ });
+});
diff --git a/src/test/common.ts b/src/test/common.ts
new file mode 100644
index 0000000000..31d1b03f15
--- /dev/null
+++ b/src/test/common.ts
@@ -0,0 +1,67 @@
+/**
+ * @copyright Valor Software
+ * @copyright Angular ng-bootstrap team
+ */
+import { TestBed, ComponentFixture } from '@angular/core/testing';
+
+export function createGenericTestComponent(html: string, type: {new (...args: any[]): T}): ComponentFixture {
+ TestBed.overrideComponent(type, {set: {template: html}});
+ const fixture = TestBed.createComponent(type);
+ fixture.detectChanges();
+ return fixture as ComponentFixture;
+}
+
+export type Browser = 'ie9' | 'ie10' | 'ie11' | 'ie' | 'edge' | 'chrome' | 'safari' | 'firefox';
+
+export function getBrowser(ua:string = window.navigator.userAgent): string {
+ let browser = 'unknown';
+
+ // IE < 11
+ const msie = ua.indexOf('MSIE ');
+ if (msie > 0) {
+ return 'ie' + parseInt(ua.substring(msie + 5, ua.indexOf('.', msie)), 10);
+ }
+
+ // IE 11
+ if (ua.indexOf('Trident/') > 0) {
+ let rv = ua.indexOf('rv:');
+ return 'ie' + parseInt(ua.substring(rv + 3, ua.indexOf('.', rv)), 10);
+ }
+
+ // Edge
+ if (ua.indexOf('Edge/') > 0) {
+ return 'edge';
+ }
+
+ // Chrome
+ if (ua.indexOf('Chrome/') > 0) {
+ return 'chrome';
+ }
+
+ // Safari
+ if (ua.indexOf('Safari/') > 0) {
+ return 'safari';
+ }
+
+ // Firefox
+ if (ua.indexOf('Firefox/') > 0) {
+ return 'firefox';
+ }
+
+ if (browser === 'unknown') {
+ throw new Error('Browser detection failed for: ' + ua);
+ }
+}
+
+export function isBrowser(browsers: Browser | Browser[], ua:string = window.navigator.userAgent):boolean {
+ let browsersStr = Array.isArray(browsers)
+ ? (browsers as Browser[]).map((x: any) => x.toString())
+ : [browsers.toString()];
+ let browser = getBrowser(ua);
+
+ if (browsersStr.indexOf('ie') > -1 && browser.startsWith('ie')) {
+ return true;
+ } else {
+ return browsersStr.indexOf(browser) > -1;
+ }
+}
diff --git a/src/tooltip/tooltip-container.component.ts b/src/tooltip/tooltip-container.component.ts
index acc13a454b..58153c8b09 100644
--- a/src/tooltip/tooltip-container.component.ts
+++ b/src/tooltip/tooltip-container.component.ts
@@ -1,73 +1,48 @@
import {
- AfterViewInit, ChangeDetectorRef, Component, ElementRef, Inject, TemplateRef
+ AfterViewInit, ChangeDetectorRef, Component, ElementRef, TemplateRef,
+ ChangeDetectionStrategy
} from '@angular/core';
-import { positionService } from '../utils/position';
-import { TooltipOptions } from './tooltip-options.class';
-
@Component({
- selector: 'tooltip-container',
- // changeDetection: ChangeDetectionStrategy.OnPush,
- template: ``
+ selector: 'bs-tooltip-container',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ // tslint:disable-next-line
+ host: {'[class]': '"tooltip in tooltip-" + placement + " " + placement', role: 'tooltip'},
+ template: `
+
+
+ `
+ // template: ``
})
export class TooltipContainerComponent implements AfterViewInit {
- /* tslint:disable */
- public classMap:any;
- public top:string = '-1000px';
- public left:string = '-1000px';
- public display:string = 'block';
- public content:string;
- public htmlContent:string | TemplateRef;
- protected placement:string;
- protected popupClass:string;
- protected animation:boolean;
- protected isOpen:boolean;
- protected appendToBody:boolean;
- protected hostEl:ElementRef;
- protected context:any;
- /* tslint:enable */
-
- protected element:ElementRef;
- protected cdr:ChangeDetectorRef;
+ public classMap: any;
+ public placement: string;
+ public popupClass: string;
+ public animation: boolean;
- public constructor(element:ElementRef,
- cdr:ChangeDetectorRef,
- @Inject(TooltipOptions) options:TooltipOptions) {
- this.element = element;
- this.cdr = cdr;
- Object.assign(this, options);
- // tslint:disable-next-line
- this.classMap = {'in': false, fade: false};
- this.classMap[options.placement] = true;
- this.classMap['tooltip-' + options.placement] = true;
- }
+ public ngAfterViewInit(): void {
+ this.classMap = {in: false, fade: false};
+ this.classMap[this.placement] = true;
+ this.classMap['tooltip-' + this.placement] = true;
- public ngAfterViewInit():void {
- let p = positionService
- .positionElements(
- this.hostEl.nativeElement,
- this.element.nativeElement.children[0],
- this.placement, this.appendToBody);
- this.top = p.top + 'px';
- this.left = p.left + 'px';
this.classMap.in = true;
if (this.animation) {
this.classMap.fade = true;
@@ -76,11 +51,5 @@ export class TooltipContainerComponent implements AfterViewInit {
if (this.popupClass) {
this.classMap[this.popupClass] = true;
}
-
- this.cdr.detectChanges();
- }
-
- public get isTemplate():boolean {
- return this.htmlContent instanceof TemplateRef;
}
}
diff --git a/src/tooltip/tooltip.config.ts b/src/tooltip/tooltip.config.ts
index 03389ed4fe..8aa36fc068 100644
--- a/src/tooltip/tooltip.config.ts
+++ b/src/tooltip/tooltip.config.ts
@@ -2,5 +2,7 @@ import { Injectable } from '@angular/core';
@Injectable()
export class TooltipConfig {
- public tooltipTrigger: string|string[] = ['mouseenter', 'focusin'];
+ public placement:string = 'top';
+ public triggers:string = 'hover focus';
+ public container: string;
}
diff --git a/src/tooltip/tooltip.directive.ts b/src/tooltip/tooltip.directive.ts
index bcb31a5412..6551e86987 100644
--- a/src/tooltip/tooltip.directive.ts
+++ b/src/tooltip/tooltip.directive.ts
@@ -1,188 +1,198 @@
import {
- ChangeDetectorRef,
- ComponentRef,
- Directive,
- HostListener,
- Input,
- ReflectiveInjector,
- TemplateRef,
- ViewContainerRef,
- Output,
- EventEmitter,
- Renderer,
- ElementRef,
- OnInit,
- OnDestroy
+ Directive, Input, TemplateRef, ViewContainerRef, Output, EventEmitter,
+ Renderer, ElementRef, OnInit, OnDestroy
} from '@angular/core';
-
import { TooltipContainerComponent } from './tooltip-container.component';
-import { TooltipOptions } from './tooltip-options.class';
-import { ComponentsHelper } from '../utils/components-helper.service';
import { TooltipConfig } from './tooltip.config';
+import { ComponentLoaderFactory, ComponentLoader } from '../component-loader';
-/* tslint:disable */
@Directive({
selector: '[tooltip], [tooltipHtml]',
exportAs: 'bs-tooltip'
})
-/* tslint:enable */
export class TooltipDirective implements OnInit, OnDestroy {
- /* tslint:disable */
- @Input('tooltip') public content: string;
- @Input('tooltipHtml') public htmlContent: string | TemplateRef;
- @Input('tooltipPlacement') public placement: string = 'top';
- @Input('tooltipIsOpen') public isOpen: boolean;
- @Input('tooltipEnable') public enable: boolean = true;
- @Input('tooltipAnimation') public animation: boolean = true;
- @Input('tooltipAppendToBody') public appendToBody: boolean = false;
- @Input('tooltipClass') public popupClass: string;
- @Input('tooltipContext') public tooltipContext: any;
- @Input('tooltipPopupDelay') public delay: number = 0;
- @Input('tooltipFadeDuration') public fadeDuration: number = 150;
- @Input('tooltipTrigger') public tooltipTrigger: string|Array;
- /* tslint:enable */
+ /**
+ * Content to be displayed as popover.
+ */
+ @Input() public tooltip: string | TemplateRef;
+ /**
+ * Title of a popover.
+ */
+ @Input() public tooltipTitle: string;
+ /**
+ * Placement of a tooltip. Accepts: "top", "bottom", "left", "right"
+ */
+ @Input() public placement: string;
+ /**
+ * Specifies events that should trigger. Supports a space separated list of
+ * event names.
+ */
+ @Input() public triggers: string;
+ /**
+ * A selector specifying the element the tooltip should be appended to.
+ * Currently only supports "body".
+ */
+ @Input() public container: string;
+
+ /**
+ * Returns whether or not the tooltip is currently being shown
+ */
+ @Input()
+ public get isOpen(): boolean { return this._tooltip.isShown; }
+
+ public set isOpen(value: boolean) {
+ if (value) {this.show();} else {this.hide();}
+ }
- @Output() public tooltipStateChanged: EventEmitter = new EventEmitter();
+ /**
+ * Allows to disable tooltip
+ */
+ @Input() public isDisabled:boolean;
- protected visible: boolean = false;
- protected tooltip: ComponentRef;
- protected delayTimeoutId: number;
- protected toggleOnShowListeners: Function[] = [];
-
- protected viewContainerRef: ViewContainerRef;
- protected componentsHelper: ComponentsHelper;
- protected changeDetectorRef: ChangeDetectorRef;
- protected renderer: Renderer;
- protected elementRef: ElementRef;
- protected config: TooltipConfig;
-
- public constructor(viewContainerRef: ViewContainerRef,
- componentsHelper: ComponentsHelper,
- changeDetectorRef: ChangeDetectorRef,
- renderer: Renderer,
- elementRef: ElementRef,
- config: TooltipConfig) {
- this.viewContainerRef = viewContainerRef;
- this.componentsHelper = componentsHelper;
- this.changeDetectorRef = changeDetectorRef;
- this.renderer = renderer;
- this.elementRef = elementRef;
- this.config = config;
- this.configureOptions();
+ /**
+ * Emits an event when the tooltip is shown
+ */
+ @Output() public onShown: EventEmitter;
+ /**
+ * Emits an event when the tooltip is hidden
+ */
+ @Output() public onHidden: EventEmitter;
+
+ /* tslint:disable */
+ /** @deprecated */
+ @Input('tooltipHtml') public set htmlContent(value: string | TemplateRef){
+ console.warn('tooltipHtml was deprecated, please use `tooltip` instead');
+ this.tooltip = value;
+ }
+ /** @deprecated */
+ @Input('tooltipPlacement') public set _placement(value: string){
+ console.warn('tooltipPlacement was deprecated, please use `placement` instead');
+ this.placement = value;
+ }
+ /** @deprecated */
+ @Input('tooltipIsOpen') public set _isOpen(value:boolean) {
+ console.warn('tooltipIsOpen was deprecated, please use `isOpen` instead');
+ this.isOpen = value;
+ }
+ public get _isOpen():boolean {
+ console.warn('tooltipIsOpen was deprecated, please use `isOpen` instead');
+ return this.isOpen;
}
- public ngOnInit(): void {
- this.bindListeners();
+ /** @deprecated */
+ @Input('tooltipEnable') public set _enable(value: boolean){
+ console.warn('tooltipEnable was deprecated, please use `isDisabled` instead');
+ this.isDisabled = value === true;
+ }
+ public get _enable(): boolean{
+ console.warn('tooltipEnable was deprecated, please use `isDisabled` instead');
+ return this.isDisabled === true;
}
- protected configureOptions(): void {
- Object.assign(this, this.config);
+ /** @deprecated */
+ @Input('tooltipAppendToBody') public set _appendToBody(value: boolean) {
+ console.warn('tooltipAppendToBody was deprecated, please use `container="body"` instead');
+ this.container = value ? 'body' : this.container;
}
- protected bindListeners(): void {
- const tooltipElement = this.elementRef.nativeElement;
- const events: string[] = this.normalizeEventsSet(this.tooltipTrigger);
- /* tslint:disable */
- for (var i = 0; i < events.length; i++) {
- const listener = this.renderer.listen(tooltipElement, events[i], this.show.bind(this));
- this.toggleOnShowListeners.push(listener);
- }
- /* tslint:enable */
+ public get _appendToBody():boolean {
+ console.warn('tooltipAppendToBody was deprecated, please use `container="body"` instead');
+ return this.container === 'body';
}
- protected normalizeEventsSet(events: string|string[]): string[] {
- if (typeof events === 'string') {
- return events.split(/[\s,]+/);
- }
+ /** @deprecated */
+ @Input('tooltipAnimation') public _animation: boolean = true;
+ /** @deprecated */
+ @Input('tooltipClass') public set _popupClass(value: string){
+ console.warn('tooltipClass deprecated');
+ }
+ /** @deprecated */
+ @Input('tooltipContext') public set _tooltipContext(value: any){
+ console.warn('tooltipContext deprecated');
+ }
+
+ @Input('tooltipPopupDelay') public _delay: number = 0;
+
+ /** @deprecated */
+ @Input('tooltipFadeDuration') public _fadeDuration: number = 150;
+
+ /** @deprecated */
+ @Input('tooltipTrigger') public get _tooltipTrigger():string|Array{
+ console.warn('tooltipTrigger was deprecated, please use `triggers` instead');
+ return this.triggers;
+ };
- return events as string[];
+ public set _tooltipTrigger(value:string|Array){
+ console.warn('tooltipTrigger was deprecated, please use `triggers` instead');
+ this.triggers = (value || '').toString();
+ };
+ /* tslint:enable */
+
+ @Output() public tooltipStateChanged: EventEmitter = new EventEmitter();
+
+ protected _delayTimeoutId: number;
+
+ private _tooltip: ComponentLoader;
+
+ // tslint:disable-next-line
+ public constructor(_viewContainerRef: ViewContainerRef,
+ _renderer: Renderer,
+ _elementRef: ElementRef,
+ cis: ComponentLoaderFactory,
+ config: TooltipConfig) {
+ this._tooltip = cis
+ .createLoader(_elementRef, _viewContainerRef, _renderer);
+
+ Object.assign(this, config);
+ this.onShown = this._tooltip.onShown;
+ this.onHidden = this._tooltip.onHidden;
}
- // params: event, target
- public show(e: MouseEvent|FocusEvent): void {
- this.preventAndStop(e);
+ public ngOnInit(): void {
+ this._tooltip.listen({
+ triggers: this.triggers,
+ show: () => this.show()
+ });
+ }
- if (this.visible || !this.enable || this.delayTimeoutId) {
+ public show(): void {
+ if (this._tooltip.isShown || this.isDisabled || this._delayTimeoutId) {
return;
}
- const showTooltip = () => {
- this.visible = true;
- let options = new TooltipOptions({
- content: this.content,
- htmlContent: this.htmlContent,
+ const showTooltip = () => this._tooltip
+ .attach(TooltipContainerComponent)
+ .to(this.container)
+ .position({attachment: this.placement})
+ .show(this.tooltip, {
placement: this.placement,
- animation: this.animation,
- appendToBody: this.appendToBody,
- hostEl: this.viewContainerRef.element,
- popupClass: this.popupClass,
- context: this.tooltipContext,
- trigger: this.tooltipTrigger
+ title: this.tooltipTitle
});
- if (this.appendToBody) {
- this.tooltip = this.componentsHelper
- .appendNextToRoot(TooltipContainerComponent, TooltipOptions, options);
- } else {
- let binding = ReflectiveInjector.resolve([
- {provide: TooltipOptions, useValue: options}
- ]);
- this.tooltip = this.componentsHelper
- .appendNextToLocation(TooltipContainerComponent, this.viewContainerRef, binding);
- }
-
- this.changeDetectorRef.markForCheck();
- this.triggerStateChanged();
- };
-
- if (this.delay) {
- this.delayTimeoutId = setTimeout(() => { showTooltip(); }, this.delay);
+ if (this._delay) {
+ this._delayTimeoutId = setTimeout(() => { showTooltip(); }, this._delay);
} else {
showTooltip();
}
}
- // params event, target
- @HostListener('mouseleave')
- @HostListener('mouseout')
- @HostListener('focusout')
- @HostListener('blur')
public hide(): void {
- if (this.delayTimeoutId) {
- clearTimeout(this.delayTimeoutId);
- this.delayTimeoutId = undefined;
+ if (this._delayTimeoutId) {
+ clearTimeout(this._delayTimeoutId);
+ this._delayTimeoutId = undefined;
}
- if (!this.visible) {
+ if (!this._tooltip.isShown) {
return;
}
- this.tooltip.instance.classMap.in = false;
- setTimeout(() => {
- this.visible = false;
- this.tooltip.destroy();
- this.triggerStateChanged();
- }, this.fadeDuration);
-
- }
-
- protected triggerStateChanged(): void {
- this.tooltipStateChanged.emit(this.visible);
- }
- protected preventAndStop(event: MouseEvent|FocusEvent): void {
- if (!event) {
- return;
- }
- event.preventDefault();
- event.stopPropagation();
+ this._tooltip.instance.classMap.in = false;
+ setTimeout(() => {
+ this._tooltip.hide();
+ }, this._fadeDuration);
}
public ngOnDestroy(): void {
- const listeners = this.toggleOnShowListeners;
- /* tslint:disable */
- for (var i = 0; i < listeners.length; i++) {
- listeners[i].call(this);
- }
- /* tslint:enable */
+ this._tooltip.dispose();
}
}
diff --git a/src/tooltip/tooltip.module.ts b/src/tooltip/tooltip.module.ts
index a7bba56c4d..78b88c935b 100644
--- a/src/tooltip/tooltip.module.ts
+++ b/src/tooltip/tooltip.module.ts
@@ -1,17 +1,22 @@
import { CommonModule } from '@angular/common';
-import { NgModule } from '@angular/core';
-
+import { NgModule, ModuleWithProviders } from '@angular/core';
import { TooltipContainerComponent } from './tooltip-container.component';
import { TooltipDirective } from './tooltip.directive';
-import { ComponentsHelper } from '../utils/components-helper.service';
import { TooltipConfig } from './tooltip.config';
+import { ComponentLoaderFactory } from '../component-loader';
+import { PositioningService } from '../positioning';
@NgModule({
imports: [CommonModule],
declarations: [TooltipDirective, TooltipContainerComponent],
- exports: [TooltipDirective, TooltipContainerComponent],
- providers: [ComponentsHelper, TooltipConfig],
+ exports: [TooltipDirective],
entryComponents: [TooltipContainerComponent]
})
export class TooltipModule {
+ public static forRoot(): ModuleWithProviders {
+ return {
+ ngModule: TooltipModule,
+ providers: [TooltipConfig, ComponentLoaderFactory, PositioningService]
+ };
+ };
}
diff --git a/src/utils/components-helper.service.ts b/src/utils/components-helper.service.ts
index 812ede1230..dc6a21e66d 100644
--- a/src/utils/components-helper.service.ts
+++ b/src/utils/components-helper.service.ts
@@ -8,6 +8,7 @@ import { DOCUMENT } from '@angular/platform-browser';
* Components helper class to easily work with
* allows to:
* - get application root view container ref
+ * @deprecated
*/
@Injectable()
export class ComponentsHelper {
@@ -19,6 +20,7 @@ export class ComponentsHelper {
public constructor(applicationRef:ApplicationRef,
componentFactoryResolver:ComponentFactoryResolver,
injector:Injector) {
+ console.warn(`ComponentsHelper is DEPRECATED, please check ComponentLoader and tooltips as a sample`);
this.applicationRef = applicationRef;
this.componentFactoryResolver = componentFactoryResolver;
this.injector = injector;
@@ -45,8 +47,10 @@ export class ComponentsHelper {
* }
* }
* ```
+ * @deprecated
*/
public setRootViewContainerRef(value:ViewContainerRef):void {
+ console.warn(`This hack is not needed any more, please remove any usage of ComponentsHelper`);
this.root = value;
}
/**
diff --git a/src/utils/position.ts b/src/utils/position.ts
index 382bedfdd3..fb2dbdc8b8 100644
--- a/src/utils/position.ts
+++ b/src/utils/position.ts
@@ -1,3 +1,6 @@
+/**
+ * @deprecated
+ */
import { KeyAttribute } from './common';
export class PositionService {
diff --git a/src/utils/trigger.class.ts b/src/utils/trigger.class.ts
new file mode 100644
index 0000000000..012e9ee277
--- /dev/null
+++ b/src/utils/trigger.class.ts
@@ -0,0 +1,16 @@
+/**
+ * @copyright Valor Software
+ * @copyright Angular ng-bootstrap team
+ */
+
+export class Trigger {
+ public open: string;
+ public close?: string;
+
+ public constructor(open: string, close?: string) {
+ this.open = open;
+ this.close = close || open;
+ }
+
+ public isManual(): boolean { return this.open === 'manual' || this.close === 'manual'; }
+}
diff --git a/src/utils/triggers.spec.ts b/src/utils/triggers.spec.ts
new file mode 100644
index 0000000000..985f38f78d
--- /dev/null
+++ b/src/utils/triggers.spec.ts
@@ -0,0 +1,124 @@
+/**
+ * @copyright Valor Software
+ * @copyright Angular ng-bootstrap team
+ */
+import { parseTriggers } from './triggers';
+
+describe('triggers', () => {
+
+ describe('parseTriggers', () => {
+
+ it('should parse single trigger', () => {
+ const t = parseTriggers('foo');
+
+ expect(t.length)
+ .toBe(1);
+ expect(t[0].open)
+ .toBe('foo');
+ expect(t[0].close)
+ .toBe('foo');
+ });
+
+ it('should parse open:close form', () => {
+ const t = parseTriggers('foo:bar');
+
+ expect(t.length)
+ .toBe(1);
+ expect(t[0].open)
+ .toBe('foo');
+ expect(t[0].close)
+ .toBe('bar');
+ });
+
+ it('should parse multiple triggers', () => {
+ const t = parseTriggers('foo:bar bar:baz');
+
+ expect(t.length)
+ .toBe(2);
+ expect(t[0].open)
+ .toBe('foo');
+ expect(t[0].close)
+ .toBe('bar');
+ expect(t[1].open)
+ .toBe('bar');
+ expect(t[1].close)
+ .toBe('baz');
+ });
+
+ it('should parse multiple triggers with mixed forms', () => {
+ const t = parseTriggers('foo bar:baz');
+
+ expect(t.length)
+ .toBe(2);
+ expect(t[0].open)
+ .toBe('foo');
+ expect(t[0].close)
+ .toBe('foo');
+ expect(t[1].open)
+ .toBe('bar');
+ expect(t[1].close)
+ .toBe('baz');
+ });
+
+ it('should properly trim excessive white-spaces', () => {
+ const t = parseTriggers('foo bar \n baz ');
+
+ expect(t.length)
+ .toBe(3);
+ expect(t[0].open)
+ .toBe('foo');
+ expect(t[0].close)
+ .toBe('foo');
+ expect(t[1].open)
+ .toBe('bar');
+ expect(t[1].close)
+ .toBe('bar');
+ expect(t[2].open)
+ .toBe('baz');
+ expect(t[2].close)
+ .toBe('baz');
+ });
+
+ it('should lookup and translate special aliases', () => {
+ const t = parseTriggers('hover');
+
+ expect(t.length)
+ .toBe(1);
+ expect(t[0].open)
+ .toBe('mouseenter');
+ expect(t[0].close)
+ .toBe('mouseleave');
+ });
+
+ it('should detect manual triggers', () => {
+ const t = parseTriggers('manual');
+
+ expect(t[0].isManual)
+ .toBeTruthy();
+ });
+
+ it('should ignore empty inputs', () => {
+ expect(parseTriggers(null).length)
+ .toBe(0);
+ expect(parseTriggers(undefined).length)
+ .toBe(0);
+ expect(parseTriggers('').length)
+ .toBe(0);
+ });
+
+ it('should throw when more than one manual trigger detected', () => {
+ expect(() => {
+ parseTriggers('manual click manual');
+ })
+ .toThrow('Triggers parse error: only one manual trigger is allowed');
+ });
+
+ it('should throw when manual trigger is mixed with other triggers', () => {
+ expect(() => {
+ parseTriggers('click manual');
+ })
+ .toThrow(`Triggers parse error: manual trigger can\'t be mixed with other triggers`);
+ });
+
+ });
+});
diff --git a/src/utils/triggers.ts b/src/utils/triggers.ts
new file mode 100644
index 0000000000..b672f502ac
--- /dev/null
+++ b/src/utils/triggers.ts
@@ -0,0 +1,63 @@
+/**
+ * @copyright Valor Software
+ * @copyright Angular ng-bootstrap team
+ */
+
+import { Renderer } from '@angular/core';
+import { Trigger } from './trigger.class';
+
+const DEFAULT_ALIASES = {
+ hover: ['mouseenter', 'mouseleave'],
+ focus: ['focusin', 'focusout']
+};
+
+export function parseTriggers(triggers: string, aliases:any = DEFAULT_ALIASES): Trigger[] {
+ const trimmedTriggers = (triggers || '').trim();
+
+ if (trimmedTriggers.length === 0) {
+ return [];
+ }
+
+ const parsedTriggers = trimmedTriggers.split(/\s+/)
+ .map((trigger: string) => trigger.split(':'))
+ .map((triggerPair: string[]) => {
+ let alias = aliases[triggerPair[0]] || triggerPair;
+ return new Trigger(alias[0], alias[1]);
+ });
+
+ const manualTriggers = parsedTriggers
+ .filter((triggerPair: Trigger) => triggerPair.isManual());
+
+ if (manualTriggers.length > 1) {
+ throw 'Triggers parse error: only one manual trigger is allowed';
+ }
+
+ if (manualTriggers.length === 1 && parsedTriggers.length > 1) {
+ throw 'Triggers parse error: manual trigger can\'t be mixed with other triggers';
+ }
+
+ return parsedTriggers;
+}
+
+export function listenToTriggers(renderer: Renderer, target: any, triggers: string,
+ showFn: Function, hideFn: Function, toggleFn: Function) {
+ const parsedTriggers = parseTriggers(triggers);
+ const listeners:any[] = [];
+
+ if (parsedTriggers.length === 1 && parsedTriggers[0].isManual()) {
+ return Function.prototype;
+ }
+
+ parsedTriggers.forEach((trigger: Trigger) => {
+ if (trigger.open === trigger.close) {
+ listeners.push(renderer.listen(target, trigger.open, toggleFn));
+ return;
+ }
+
+ listeners.push(
+ renderer.listen(target, trigger.open, showFn),
+ renderer.listen(target, trigger.close, hideFn));
+ });
+
+ return () => { listeners.forEach((unsubscribeFn:Function) => unsubscribeFn()); };
+}
diff --git a/tslint.json b/tslint.json
index d9f2a1605f..aaeff52c18 100644
--- a/tslint.json
+++ b/tslint.json
@@ -3,6 +3,7 @@
"rulesDirectory": "./node_modules/codelyzer",
"rules": {
"no-forward-ref": false,
+ "no-null-keyword": false,
"only-arrow-functions": false,
"no-access-missing-member": false,
"directive-selector": false,