();
- this.placementChanges = this.placementSubject.asObservable();
- }
+ private windowRef: SkyWindowRefService) { }
public setPopoverPosition(
elements: SkyPopoverAdapterElements,
- placement: SkyPopoverPlacement
+ placement: SkyPopoverPlacement = 'above'
) {
this.clearElementCoordinates(elements.popover);
this.clearElementCoordinates(elements.popoverArrow);
@@ -79,8 +69,6 @@ export class SkyPopoverAdapterService {
coords = this.getCoordinates(elements, placement);
}
- this.sendChanges(placement);
-
return coords;
}
@@ -105,8 +93,9 @@ export class SkyPopoverAdapterService {
let isOutsideViewport = false;
+ // tslint:disable:switch-default
+ // All possible types are represented; default unnecessary.
switch (placement) {
- default:
case 'above':
left = leftCenter;
top = callerRect.top - popoverRect.height;
@@ -131,6 +120,7 @@ export class SkyPopoverAdapterService {
arrowTop = (popoverRect.height / 2);
break;
}
+ // tslint:enable:switch-default
left += window.pageXOffset;
top += window.pageYOffset;
@@ -198,8 +188,19 @@ export class SkyPopoverAdapterService {
}
private setElementCoordinates(elem: ElementRef, top: number, left: number) {
- this.renderer.setStyle(elem.nativeElement, 'top', `${top}px`);
- this.renderer.setStyle(elem.nativeElement, 'left', `${left}px`);
+ let topStyle;
+ let leftStyle;
+
+ if (top !== undefined) {
+ topStyle = `${top}px`;
+ }
+
+ if (left !== undefined) {
+ leftStyle = `${left}px`;
+ }
+
+ this.renderer.setStyle(elem.nativeElement, 'top', topStyle);
+ this.renderer.setStyle(elem.nativeElement, 'left', leftStyle);
}
private getNextPlacement(placement: SkyPopoverPlacement): SkyPopoverPlacement {
@@ -217,8 +218,4 @@ export class SkyPopoverAdapterService {
const pairings = { above: 'below', below: 'above', right: 'left', left: 'right' };
return pairings[placement] as SkyPopoverPlacement;
}
-
- private sendChanges(placement: SkyPopoverPlacement) {
- this.placementSubject.next(placement);
- }
}
diff --git a/src/modules/popover/popover-trigger.ts b/src/modules/popover/popover-trigger.ts
new file mode 100644
index 000000000..25c769aab
--- /dev/null
+++ b/src/modules/popover/popover-trigger.ts
@@ -0,0 +1 @@
+export type SkyPopoverTrigger = 'click' | 'mouseenter';
diff --git a/src/modules/popover/popover.component.html b/src/modules/popover/popover.component.html
index 0a86a3052..f3071f1e6 100644
--- a/src/modules/popover/popover.component.html
+++ b/src/modules/popover/popover.component.html
@@ -1,5 +1,5 @@
{
beforeEach(() => {
let mockWindowService = new MockWindowService();
let mockAdapterService = {
- placementChanges: Observable.of('above'),
setPopoverPosition() {},
hidePopover() {},
showPopover() {}
@@ -74,23 +73,23 @@ describe('SkyPopoverComponent', () => {
it('should call the adapter service to position the popover', () => {
const caller = new ElementRef({});
- spyOn(component['adapterService'], 'setPopoverPosition');
+ const spy = spyOn(component['adapterService'], 'setPopoverPosition');
component.positionNextTo(caller, 'above');
- expect(component['adapterService'].setPopoverPosition).toHaveBeenCalled();
+ expect(spy).toHaveBeenCalled();
});
it('should call the adapter service with a default position', () => {
const caller = new ElementRef({});
- spyOn(component['adapterService'], 'setPopoverPosition');
+ const spy = spyOn(component['adapterService'], 'setPopoverPosition');
component.positionNextTo(caller, undefined);
- expect(component['adapterService'].setPopoverPosition).toHaveBeenCalled();
+ expect(spy).toHaveBeenCalled();
expect(component.placement).toEqual('above');
});
it('should not call the adapter service if a caller is not defined', () => {
- spyOn(component['adapterService'], 'setPopoverPosition');
+ const spy = spyOn(component['adapterService'], 'setPopoverPosition');
component.positionNextTo(undefined, 'above');
- expect(component['adapterService'].setPopoverPosition).not.toHaveBeenCalled();
+ expect(spy).not.toHaveBeenCalled();
});
it('should close a popover', () => {
@@ -99,13 +98,9 @@ describe('SkyPopoverComponent', () => {
expect(component.isOpen).toEqual(false);
});
- it('should get a CSS classname to represent its placement', () => {
- const element = fixture.debugElement.query(By.css('.sky-popover-placement-above'));
- expect(Boolean(element)).not.toEqual(false);
- });
-
it('should remove a CSS classname before the animation starts', () => {
- spyOn(component['adapterService'], 'showPopover').and.returnValue(0);
+ const spy = spyOn(component['adapterService'], 'showPopover').and.returnValue(0);
+
component.onAnimationStart({
fromState: 'hidden',
toState: 'visible',
@@ -114,11 +109,11 @@ describe('SkyPopoverComponent', () => {
element: {},
triggerName: ''
});
- expect(component['adapterService'].showPopover)
- .toHaveBeenCalledWith(component.popoverContainer);
+
+ expect(spy).toHaveBeenCalledWith(component.popoverContainer);
// Handle 'else' path:
- (component['adapterService'].showPopover as any).calls.reset();
+ spy.calls.reset();
component.onAnimationStart({
fromState: 'visible',
toState: 'hidden',
@@ -128,13 +123,12 @@ describe('SkyPopoverComponent', () => {
triggerName: ''
});
- expect(component['adapterService'].showPopover)
- .not
- .toHaveBeenCalled();
+ expect(spy).not.toHaveBeenCalled();
});
it('should add a CSS classname when the animation stops', () => {
- spyOn(component['adapterService'], 'hidePopover').and.returnValue(0);
+ const spy = spyOn(component['adapterService'], 'hidePopover').and.returnValue(0);
+
component.onAnimationDone({
fromState: 'visible',
toState: 'hidden',
@@ -143,12 +137,13 @@ describe('SkyPopoverComponent', () => {
element: {},
triggerName: ''
});
- expect(component['adapterService'].hidePopover)
- .toHaveBeenCalledWith(component.popoverContainer);
+
+ expect(spy).toHaveBeenCalledWith(component.popoverContainer);
});
it('should emit an event when the popover is opened', () => {
- spyOn(component.popoverOpened, 'emit').and.returnValue(0);
+ const spy = spyOn(component.popoverOpened, 'emit').and.returnValue(0);
+
component.onAnimationDone({
fromState: 'hidden',
toState: 'visible',
@@ -157,12 +152,13 @@ describe('SkyPopoverComponent', () => {
element: {},
triggerName: ''
});
- expect(component.popoverOpened.emit)
- .toHaveBeenCalledWith(component);
+
+ expect(spy).toHaveBeenCalledWith(component);
});
it('should emit an event when the popover is closed', () => {
- spyOn(component.popoverClosed, 'emit').and.returnValue(0);
+ const spy = spyOn(component.popoverClosed, 'emit').and.returnValue(0);
+
component.onAnimationDone({
fromState: 'visible',
toState: 'hidden',
@@ -171,8 +167,8 @@ describe('SkyPopoverComponent', () => {
element: {},
triggerName: ''
});
- expect(component.popoverClosed.emit)
- .toHaveBeenCalledWith(component);
+
+ expect(spy).toHaveBeenCalledWith(component);
});
it('should get the animation state', () => {
@@ -184,12 +180,6 @@ describe('SkyPopoverComponent', () => {
expect(state).toEqual('visible');
});
- it('should unsubscribe from the placement observable', () => {
- spyOn(component['placementSubscription'], 'unsubscribe');
- component.ngOnDestroy();
- expect(component['placementSubscription'].unsubscribe).toHaveBeenCalled();
- });
-
it('should capture mouse enter and mouse leave events', () => {
expect(component['isMouseEnter']).toEqual(false);
TestUtility.fireDomEvent(fixture.nativeElement, 'mouseenter');
@@ -256,4 +246,12 @@ describe('SkyPopoverComponent', () => {
fixture.detectChanges();
expect(component.close).not.toHaveBeenCalled();
});
+
+ it('should close the popover on mouseleave if it has been marked for close', () => {
+ const spy = spyOn(component, 'close');
+ component.markForCloseOnMouseLeave();
+ TestUtility.fireDomEvent(fixture.nativeElement, 'mouseleave');
+ expect(spy).toHaveBeenCalledWith();
+ expect(component['isMarkedForCloseOnMouseLeave']).toEqual(false);
+ });
});
diff --git a/src/modules/popover/popover.component.ts b/src/modules/popover/popover.component.ts
index 2008bb6a9..170666c4e 100644
--- a/src/modules/popover/popover.component.ts
+++ b/src/modules/popover/popover.component.ts
@@ -6,8 +6,6 @@ import {
EventEmitter,
HostListener,
Input,
- OnInit,
- OnDestroy,
Output,
ViewChild
} from '@angular/core';
@@ -21,8 +19,6 @@ import {
transition
} from '@angular/animations';
-import { Subscription } from 'rxjs/Subscription';
-
import { SkyWindowRefService } from '../window';
import { SkyPopoverPlacement, SkyPopoverAdapterService } from './index';
@@ -41,7 +37,7 @@ import { SkyPopoverPlacement, SkyPopoverAdapterService } from './index';
],
changeDetection: ChangeDetectionStrategy.OnPush
})
-export class SkyPopoverComponent implements OnInit, OnDestroy {
+export class SkyPopoverComponent {
@Input()
public popoverTitle: string;
@@ -61,11 +57,11 @@ export class SkyPopoverComponent implements OnInit, OnDestroy {
public popoverArrow: ElementRef;
public isOpen = false;
public placementClassName: string;
+ public isMouseEnter = false;
+ private isMarkedForCloseOnMouseLeave = false;
private lastCaller: ElementRef;
- private isMouseEnter = false;
private readonly placementDefault: SkyPopoverPlacement = 'above';
- private placementSubscription: Subscription;
constructor(
private windowRef: SkyWindowRefService,
@@ -75,11 +71,6 @@ export class SkyPopoverComponent implements OnInit, OnDestroy {
this.placement = this.placementDefault;
this.popoverOpened = new EventEmitter();
this.popoverClosed = new EventEmitter();
- this.placementSubscription = this.adapterService.placementChanges
- .subscribe((placement: SkyPopoverPlacement) => {
- this.placement = placement;
- this.placementClassName = this.getPlacementClassName();
- });
}
@HostListener('window:resize')
@@ -109,10 +100,11 @@ export class SkyPopoverComponent implements OnInit, OnDestroy {
@HostListener('mouseleave')
public onMouseLeave() {
this.isMouseEnter = false;
- }
- public ngOnInit(): void {
- this.placementClassName = this.getPlacementClassName();
+ if (this.isMarkedForCloseOnMouseLeave) {
+ this.close();
+ this.isMarkedForCloseOnMouseLeave = false;
+ }
}
public positionNextTo(caller: ElementRef, placement: SkyPopoverPlacement) {
@@ -128,10 +120,13 @@ export class SkyPopoverComponent implements OnInit, OnDestroy {
caller: this.lastCaller
};
+ this.placement = placement || this.placementDefault;
+ this.changeDetector.markForCheck();
+
// Wait for a tick to allow placement styles to render.
// (The styles affect the element dimensions.)
this.windowRef.getWindow().setTimeout(() => {
- this.adapterService.setPopoverPosition(elements, placement || this.placementDefault);
+ this.adapterService.setPopoverPosition(elements, this.placement);
this.isOpen = true;
this.changeDetector.markForCheck();
});
@@ -140,6 +135,7 @@ export class SkyPopoverComponent implements OnInit, OnDestroy {
public close() {
this.lastCaller = undefined;
this.isOpen = false;
+ this.changeDetector.markForCheck();
}
public onAnimationStart(event: AnimationEvent) {
@@ -169,11 +165,7 @@ export class SkyPopoverComponent implements OnInit, OnDestroy {
return (this.isOpen) ? 'visible' : 'hidden';
}
- public ngOnDestroy(): void {
- this.placementSubscription.unsubscribe();
- }
-
- private getPlacementClassName(): string {
- return `sky-popover-placement-${this.placement}`;
+ public markForCloseOnMouseLeave() {
+ this.isMarkedForCloseOnMouseLeave = true;
}
}
diff --git a/src/modules/popover/popover.directive.spec.ts b/src/modules/popover/popover.directive.spec.ts
index 473bd5cac..58cd15a64 100644
--- a/src/modules/popover/popover.directive.spec.ts
+++ b/src/modules/popover/popover.directive.spec.ts
@@ -42,6 +42,74 @@ describe('SkyPopoverDirective', () => {
let component: SkyPopoverTestComponent;
let directiveElements: DebugElement[];
+ function triggerMouseEvent(el: DebugElement, eventName: string) {
+ el.triggerEventHandler(
+ eventName,
+ {
+ preventDefault: () => {}
+ }
+ );
+ }
+
+ function validateTriggerOpensPopover(
+ elIndex: number,
+ openTrigger: string,
+ closeTrigger: string
+ ) {
+ const allOpenTriggers = [
+ 'click',
+ 'mouseenter'
+ ];
+
+ const allCloseTriggers = [
+ 'click',
+ 'mouseleave'
+ ];
+
+ const caller = directiveElements[elIndex];
+ const callerInstance = caller.injector.get(SkyPopoverDirective);
+
+ const positionNextToSpy = spyOn(callerInstance.skyPopover, 'positionNextTo');
+ const closeSpy = spyOn(callerInstance.skyPopover, 'close');
+
+ // The popover shouldn't be opened on other triggers.
+ for (const supportedTrigger of allOpenTriggers) {
+ if (supportedTrigger !== openTrigger) {
+ triggerMouseEvent(caller, supportedTrigger);
+
+ expect(positionNextToSpy).not.toHaveBeenCalled();
+ }
+ }
+
+ triggerMouseEvent(caller, openTrigger);
+
+ expect(positionNextToSpy).toHaveBeenCalled();
+
+ callerInstance.skyPopover.isOpen = true;
+
+ // The popover shouldn't be closed on other triggers.
+ for (const supportedCloseTrigger of allCloseTriggers) {
+ if (supportedCloseTrigger !== closeTrigger) {
+ triggerMouseEvent(caller, supportedCloseTrigger);
+
+ expect(closeSpy).not.toHaveBeenCalled();
+ }
+ }
+
+ triggerMouseEvent(caller, closeTrigger);
+
+ expect(closeSpy).toHaveBeenCalled();
+
+ // Make sure close isn't called again when the popover is already closed.
+ closeSpy.calls.reset();
+
+ callerInstance.skyPopover.isOpen = false;
+
+ triggerMouseEvent(caller, closeTrigger);
+
+ expect(closeSpy).not.toHaveBeenCalled();
+ }
+
beforeEach(() => {
let mockWindowService = new MockWindowService();
let mockAdapterService = {};
@@ -73,9 +141,7 @@ describe('SkyPopoverDirective', () => {
const callerInstance = caller.injector.get(SkyPopoverDirective);
spyOn(callerInstance.skyPopover, 'positionNextTo');
- fixture.detectChanges();
- caller.nativeElement.click();
- fixture.detectChanges();
+ triggerMouseEvent(caller, 'click');
expect(callerInstance.skyPopover.positionNextTo)
.toHaveBeenCalledWith(callerInstance.elementRef, undefined);
@@ -88,8 +154,7 @@ describe('SkyPopoverDirective', () => {
callerInstance.skyPopover.isOpen = true;
spyOn(callerInstance.skyPopover, 'close');
- caller.nativeElement.click();
- fixture.detectChanges();
+ triggerMouseEvent(caller, 'click');
expect(callerInstance.skyPopover.close)
.toHaveBeenCalledWith();
@@ -101,11 +166,27 @@ describe('SkyPopoverDirective', () => {
spyOn(callerInstance.skyPopover, 'positionNextTo');
- fixture.detectChanges();
- caller.nativeElement.click();
- fixture.detectChanges();
+ triggerMouseEvent(caller, 'click');
expect(callerInstance.skyPopover.positionNextTo)
.toHaveBeenCalledWith(callerInstance.elementRef, 'below');
});
+
+ it('should allow click to display the popover', () => {
+ validateTriggerOpensPopover(1, 'click', 'click');
+ });
+
+ it('should allow mouseenter to display the popover', () => {
+ validateTriggerOpensPopover(2, 'mouseenter', 'mouseleave');
+ });
+
+ it('should mark the popover to close on mouseleave', () => {
+ const caller = directiveElements[2];
+ const callerInstance = caller.injector.get(SkyPopoverDirective);
+ const spy = spyOn(callerInstance.skyPopover, 'markForCloseOnMouseLeave');
+ callerInstance.skyPopover.isOpen = true;
+ callerInstance.skyPopover.isMouseEnter = true;
+ triggerMouseEvent(caller, 'mouseleave');
+ expect(spy).toHaveBeenCalledWith();
+ });
});
diff --git a/src/modules/popover/popover.directive.ts b/src/modules/popover/popover.directive.ts
index ade9f2a37..9dd7d4dae 100644
--- a/src/modules/popover/popover.directive.ts
+++ b/src/modules/popover/popover.directive.ts
@@ -5,9 +5,14 @@ import {
Input
} from '@angular/core';
+import {
+ SkyWindowRefService
+} from '../window';
+
import {
SkyPopoverComponent,
- SkyPopoverPlacement
+ SkyPopoverPlacement,
+ SkyPopoverTrigger
} from './index';
@Directive({
@@ -20,18 +25,53 @@ export class SkyPopoverDirective {
@Input()
public skyPopoverPlacement: SkyPopoverPlacement;
+ @Input()
+ public skyPopoverTrigger: SkyPopoverTrigger = 'click';
+
constructor(
- public elementRef: ElementRef) { }
+ public elementRef: ElementRef,
+ private windowRef: SkyWindowRefService
+ ) { }
@HostListener('click', ['$event'])
public togglePopover(event: MouseEvent) {
- event.preventDefault();
+ if (this.skyPopoverTrigger === 'click') {
+ event.preventDefault();
+
+ if (this.skyPopover.isOpen) {
+ this.skyPopover.close();
+ return;
+ }
+
+ this.skyPopover.positionNextTo(this.elementRef, this.skyPopoverPlacement);
+ }
+ }
+
+ @HostListener('mouseenter', ['$event'])
+ public onMouseEnter(event: MouseEvent) {
+ if (this.skyPopoverTrigger === 'mouseenter') {
+ event.preventDefault();
- if (this.skyPopover.isOpen) {
- this.skyPopover.close();
- return;
+ this.skyPopover.positionNextTo(this.elementRef, this.skyPopoverPlacement);
}
+ }
+
+ @HostListener('mouseleave', ['$event'])
+ public onMouseLeave(event: MouseEvent) {
+ if (this.skyPopoverTrigger === 'mouseenter') {
+ event.preventDefault();
- this.skyPopover.positionNextTo(this.elementRef, this.skyPopoverPlacement);
+ // Give the popover a chance to set its isMouseEnter flag before checking to see
+ // if it should be closed.
+ this.windowRef.getWindow().setTimeout(() => {
+ if (this.skyPopover.isOpen) {
+ if (this.skyPopover.isMouseEnter) {
+ this.skyPopover.markForCloseOnMouseLeave();
+ } else {
+ this.skyPopover.close();
+ }
+ }
+ });
+ }
}
}