Skip to content

Commit

Permalink
feat: add support for "auto" placement and multi-placement
Browse files Browse the repository at this point in the history
Closes #992
Closes #1782
  • Loading branch information
mschoudry authored and pkozlowski-opensource committed Sep 4, 2017
1 parent 1432f47 commit b73023a
Show file tree
Hide file tree
Showing 7 changed files with 211 additions and 37 deletions.
4 changes: 2 additions & 2 deletions src/popover/popover-config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Injectable} from '@angular/core';
import {Placement} from '../util/positioning';
import {PlacementArray} from '../util/positioning';

/**
* Configuration service for the NgbPopover directive.
Expand All @@ -8,7 +8,7 @@ import {Placement} from '../util/positioning';
*/
@Injectable()
export class NgbPopoverConfig {
placement: Placement = 'top';
placement: PlacementArray = 'top';
triggers = 'click';
container: string;
}
29 changes: 29 additions & 0 deletions src/popover/popover.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,35 @@ describe('ngb-popover', () => {
expect(windowEl).toHaveCssClass('bs-popover-right-top');
expect(windowEl.textContent.trim()).toBe('Great tip!');
});

it('should accept placement in array(second value of the array should be applied)', () => {
const fixture = createTestComponent(`<div ngbPopover="Great tip!" [placement]="['left-top','top-right']"></div>`);
const directive = fixture.debugElement.query(By.directive(NgbPopover));

directive.triggerEventHandler('click', {});
fixture.detectChanges();
const windowEl = getWindow(fixture.nativeElement);

expect(windowEl).toHaveCssClass('popover');
expect(windowEl).toHaveCssClass('bs-popover-top');
expect(windowEl).toHaveCssClass('bs-popover-top-right');
expect(windowEl.textContent.trim()).toBe('Great tip!');
});

it('should apply auto placement', () => {
const fixture = createTestComponent(`<div ngbPopover="Great tip!" placement="auto"></div>`);
const directive = fixture.debugElement.query(By.directive(NgbPopover));

directive.triggerEventHandler('click', {});
fixture.detectChanges();
const windowEl = getWindow(fixture.nativeElement);

expect(windowEl).toHaveCssClass('popover');
// actual placement with auto is not known in advance, so use regex to check it
expect(windowEl.getAttribute('class')).toMatch('bs-popover-\.');
expect(windowEl.textContent.trim()).toBe('Great tip!');
});

});

describe('container', () => {
Expand Down
42 changes: 30 additions & 12 deletions src/popover/popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
} from '@angular/core';

import {listenToTriggers} from '../util/triggers';
import {positionElements, Placement} from '../util/positioning';
import {positionElements, Placement, PlacementArray} from '../util/positioning';
import {PopupService} from '../util/popup';
import {NgbPopoverConfig} from './popover-config';

Expand Down Expand Up @@ -67,6 +67,21 @@ export class NgbPopoverWindow {
@Input() placement: Placement = 'top';
@Input() title: string;
@Input() id: string;

constructor(private _element: ElementRef, private _renderer: Renderer2) {}

applyPlacement(_placement: Placement) {
// remove the current placement classes
this._renderer.removeClass(this._element.nativeElement, 'bs-popover-' + this.placement.toString().split('-')[0]);
this._renderer.removeClass(this._element.nativeElement, 'bs-popover-' + this.placement.toString());

// set the new placement classes
this.placement = _placement;

// apply the new placement
this._renderer.addClass(this._element.nativeElement, 'bs-popover-' + this.placement.toString().split('-')[0]);
this._renderer.addClass(this._element.nativeElement, 'bs-popover-' + this.placement.toString());
}
}

/**
Expand All @@ -86,8 +101,9 @@ export class NgbPopover implements OnInit, OnDestroy {
* Placement of a popover accepts:
* "top", "top-left", "top-right", "bottom", "bottom-left", "bottom-right",
* "left", "left-top", "left-bottom", "right", "right-top", "right-bottom"
* and array of above values.
*/
@Input() placement: Placement;
@Input() placement: PlacementArray;
/**
* Specifies events that should trigger. Supports a space separated list of event names.
*/
Expand Down Expand Up @@ -124,9 +140,10 @@ export class NgbPopover implements OnInit, OnDestroy {

this._zoneSubscription = ngZone.onStable.subscribe(() => {
if (this._windowRef) {
positionElements(
this._elementRef.nativeElement, this._windowRef.location.nativeElement, this.placement.toString(),
this.container === 'body');
this._windowRef.instance.applyPlacement(
positionElements(
this._elementRef.nativeElement, this._windowRef.location.nativeElement, this.placement,
this.container === 'body'));
}
});
}
Expand All @@ -138,7 +155,6 @@ export class NgbPopover implements OnInit, OnDestroy {
open(context?: any) {
if (!this._windowRef) {
this._windowRef = this._popupService.open(this.ngbPopover, context);
this._windowRef.instance.placement = this.placement;
this._windowRef.instance.title = this.popoverTitle;
this._windowRef.instance.id = this._ngbPopoverWindowId;

Expand All @@ -148,14 +164,16 @@ export class NgbPopover implements OnInit, OnDestroy {
window.document.querySelector(this.container).appendChild(this._windowRef.location.nativeElement);
}

// apply styling to set basic css-classes on target element, before going for positioning
this._windowRef.changeDetectorRef.detectChanges();
this._windowRef.changeDetectorRef.markForCheck();

// position popover along the element
positionElements(
this._elementRef.nativeElement, this._windowRef.location.nativeElement, this.placement.toString(),
this.container === 'body');
this._windowRef.instance.applyPlacement(
positionElements(
this._elementRef.nativeElement, this._windowRef.location.nativeElement, this.placement,
this.container === 'body'));

// 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._windowRef.changeDetectorRef.markForCheck();
this.shown.emit();
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/tooltip/tooltip-config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Injectable} from '@angular/core';
import {Placement} from '../util/positioning';
import {PlacementArray} from '../util/positioning';

/**
* Configuration service for the NgbTooltip directive.
Expand All @@ -8,7 +8,7 @@ import {Placement} from '../util/positioning';
*/
@Injectable()
export class NgbTooltipConfig {
placement: Placement = 'top';
placement: PlacementArray = 'top';
triggers = 'hover';
container: string;
}
30 changes: 30 additions & 0 deletions src/tooltip/tooltip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,36 @@ describe('ngb-tooltip', () => {
expect(windowEl).toHaveCssClass('bs-tooltip-right-top');
expect(windowEl.textContent.trim()).toBe('Great tip!');
});

it('should accept placement in array(second value of the array should be applied)', () => {
const fixture =
createTestComponent(`<div ngbTooltip="Great tip!" [placement]="['left-top','top-right']"></div>`);
const directive = fixture.debugElement.query(By.directive(NgbTooltip));

directive.triggerEventHandler('mouseenter', {});
fixture.detectChanges();
const windowEl = getWindow(fixture.nativeElement);

expect(windowEl).toHaveCssClass('tooltip');
expect(windowEl).toHaveCssClass('bs-tooltip-top');
expect(windowEl).toHaveCssClass('bs-tooltip-top-right');
expect(windowEl.textContent.trim()).toBe('Great tip!');
});

it('should apply auto placement', () => {
const fixture = createTestComponent(`<div ngbTooltip="Great tip!" placement="auto"></div>`);
const directive = fixture.debugElement.query(By.directive(NgbTooltip));

directive.triggerEventHandler('mouseenter', {});
fixture.detectChanges();
const windowEl = getWindow(fixture.nativeElement);

expect(windowEl).toHaveCssClass('tooltip');
// actual placement with auto is not known in advance, so use regex to check it
expect(windowEl.getAttribute('class')).toMatch('bs-tooltip-\.');
expect(windowEl.textContent.trim()).toBe('Great tip!');
});

});

describe('triggers', () => {
Expand Down
52 changes: 36 additions & 16 deletions src/tooltip/tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
NgZone
} from '@angular/core';
import {listenToTriggers} from '../util/triggers';
import {positionElements, Placement} from '../util/positioning';
import {positionElements, Placement, PlacementArray} from '../util/positioning';
import {PopupService} from '../util/popup';
import {NgbTooltipConfig} from './tooltip-config';

Expand Down Expand Up @@ -63,6 +63,21 @@ let nextId = 0;
export class NgbTooltipWindow {
@Input() placement: Placement = 'top';
@Input() id: string;

constructor(private _element: ElementRef, private _renderer: Renderer2) {}

applyPlacement(_placement: Placement) {
// remove the current placement classes
this._renderer.removeClass(this._element.nativeElement, 'bs-tooltip-' + this.placement.toString().split('-')[0]);
this._renderer.removeClass(this._element.nativeElement, 'bs-tooltip-' + this.placement.toString());

// set the new placement classes
this.placement = _placement;

// apply the new placement
this._renderer.addClass(this._element.nativeElement, 'bs-tooltip-' + this.placement.toString().split('-')[0]);
this._renderer.addClass(this._element.nativeElement, 'bs-tooltip-' + this.placement.toString());
}
}

/**
Expand All @@ -71,11 +86,12 @@ export class NgbTooltipWindow {
@Directive({selector: '[ngbTooltip]', exportAs: 'ngbTooltip'})
export class NgbTooltip implements OnInit, OnDestroy {
/**
* Placement of a tooltip accepts:
* "top", "top-left", "top-right", "bottom", "bottom-left", "bottom-right",
* "left", "left-top", "left-bottom", "right", "right-top", "right-bottom"
*/
@Input() placement: Placement;
* Placement of a popover accepts:
* "top", "top-left", "top-right", "bottom", "bottom-left", "bottom-right",
* "left", "left-top", "left-bottom", "right", "right-top", "right-bottom"
* and array of above values.
*/
@Input() placement: PlacementArray;
/**
* Specifies events that should trigger. Supports a space separated list of event names.
*/
Expand Down Expand Up @@ -113,9 +129,10 @@ export class NgbTooltip implements OnInit, OnDestroy {

this._zoneSubscription = ngZone.onStable.subscribe(() => {
if (this._windowRef) {
positionElements(
this._elementRef.nativeElement, this._windowRef.location.nativeElement, this.placement.toString(),
this.container === 'body');
this._windowRef.instance.applyPlacement(
positionElements(
this._elementRef.nativeElement, this._windowRef.location.nativeElement, this.placement,
this.container === 'body'));
}
});
}
Expand All @@ -140,7 +157,6 @@ export class NgbTooltip implements OnInit, OnDestroy {
open(context?: any) {
if (!this._windowRef && this._ngbTooltip) {
this._windowRef = this._popupService.open(this._ngbTooltip, context);
this._windowRef.instance.placement = this.placement;
this._windowRef.instance.id = this._ngbTooltipWindowId;

this._renderer.setAttribute(this._elementRef.nativeElement, 'aria-describedby', this._ngbTooltipWindowId);
Expand All @@ -149,14 +165,18 @@ export class NgbTooltip implements OnInit, OnDestroy {
window.document.querySelector(this.container).appendChild(this._windowRef.location.nativeElement);
}

// position tooltip along the element
positionElements(
this._elementRef.nativeElement, this._windowRef.location.nativeElement, this.placement.toString(),
this.container === 'body');
this._windowRef.instance.placement = Array.isArray(this.placement) ? this.placement[0] : this.placement;

// we need to manually invoke change detection since events registered via
// Renderer::listen() - to be determined if this is a bug in the Angular itself
// apply styling to set basic css-classes on target element, before going for positioning
this._windowRef.changeDetectorRef.detectChanges();
this._windowRef.changeDetectorRef.markForCheck();

// position tooltip along the element
this._windowRef.instance.applyPlacement(
positionElements(
this._elementRef.nativeElement, this._windowRef.location.nativeElement, this.placement,
this.container === 'body'));

this.shown.emit();
}
}
Expand Down
87 changes: 82 additions & 5 deletions src/util/positioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,16 +144,93 @@ export class Positioning {

return targetElPosition;
}

// get the availble placements of the target element in the viewport dependeing on the host element
getAvailablePlacements(hostElement: HTMLElement, targetElement: HTMLElement): string[] {
let availablePlacements: Array<string> = [];
let hostElemClientRect = hostElement.getBoundingClientRect();
let targetElemClientRect = targetElement.getBoundingClientRect();
let html = document.documentElement;

// left: check if target width can be placed between host left and viewport start and also height of target is
// inside viewport
if (hostElemClientRect.left - targetElemClientRect.width > 0 &&
(hostElemClientRect.top + hostElemClientRect.height / 2 - targetElement.offsetHeight / 2) > 0) {
availablePlacements.splice(availablePlacements.length, 1, 'left');
}

// top: target height is less than host top
if (targetElemClientRect.height < hostElemClientRect.top) {
availablePlacements.splice(availablePlacements.length, 1, 'top');
}

// right: check if target width can be placed between host right and viewport end and also height of target is
// inside viewport
if ((window.innerWidth || html.clientWidth) - hostElemClientRect.right > targetElemClientRect.width &&
(hostElemClientRect.top + hostElemClientRect.height / 2 - targetElement.offsetHeight / 2) > 0) {
availablePlacements.splice(availablePlacements.length, 1, 'right');
}

// bottom: check if there is enough space between host bottom and viewport end for target height
if ((window.innerHeight || html.clientHeight) - hostElemClientRect.bottom > targetElemClientRect.height) {
availablePlacements.splice(availablePlacements.length, 1, 'bottom');
}

return availablePlacements;
}
}

const positionService = new Positioning();

/*
* Accept the placement array and applies the appropriate placement dependent on the viewport.
* Returns the applied placement.
* In case of auto placement, placements are selected in order 'top', 'bottom', 'left', 'right'.
* */
export function positionElements(
hostElement: HTMLElement, targetElement: HTMLElement, placement: string, appendToBody?: boolean): void {
const pos = positionService.positionElements(hostElement, targetElement, placement, appendToBody);
hostElement: HTMLElement, targetElement: HTMLElement, placement: string | Placement | PlacementArray,
appendToBody?: boolean): Placement {
let placementVals: Array<Placement> = Array.isArray(placement) ? placement : [placement as Placement];

// replace auto placement with other placements
let hasAuto = placementVals.findIndex(val => val === 'auto');
if (hasAuto >= 0) {
['top', 'right', 'bottom', 'left'].forEach(function(obj) {
if (placementVals.find(val => val.search('^' + obj + '|^' + obj + '-') !== -1) == null) {
placementVals.splice(hasAuto++, 1, obj as Placement);
}
});
}

targetElement.style.top = `${pos.top}px`;
targetElement.style.left = `${pos.left}px`;
// coordinates where to position
let topVal = 0, leftVal = 0;
let appliedPlacement: Placement;
// get available placements
let availablePlacements = positionService.getAvailablePlacements(hostElement, targetElement);

// iterate over all the passed placements
for (let { item, index } of toItemIndexes(placementVals)) {
// check if passed placement is present in the available placement or otherwise apply the last placement in the
// passed placement list
if ((availablePlacements.find(val => val === item.split('-')[0]) != null) || (placementVals.length === index + 1)) {
appliedPlacement = <Placement>item;
const pos = positionService.positionElements(hostElement, targetElement, item, appendToBody);
topVal = pos.top;
leftVal = pos.left;
break;
}
}
targetElement.style.top = `${topVal}px`;
targetElement.style.left = `${leftVal}px`;
return appliedPlacement;
}

// function to get index and item of an array
function toItemIndexes<T>(a: T[]) {
return a.map((item, index) => ({item, index}));
}

export type Placement = 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' |
export type Placement = 'auto' | 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' |
'bottom-right' | 'left-top' | 'left-bottom' | 'right-top' | 'right-bottom';

export type PlacementArray = Placement | Array<Placement>;

0 comments on commit b73023a

Please sign in to comment.