Skip to content

Commit

Permalink
[added] Position component for custom Overlays
Browse files Browse the repository at this point in the history
  • Loading branch information
jquense committed Jun 30, 2015
1 parent f799110 commit 1638f69
Show file tree
Hide file tree
Showing 4 changed files with 319 additions and 2 deletions.
14 changes: 12 additions & 2 deletions docs/src/ComponentsPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -792,7 +792,7 @@ const ComponentsPage = React.createClass({
<div className='bs-docs-section'>
<h1 id='utilities' className='page-header'>Utilities <small>Portal</small></h1>

<h2 id='utilities-Portal'>Portal</h2>
<h2 id='utilities-portal'>Portal</h2>
<p>
A Component that renders its children into a new React "subtree" or <code>container</code>. The Portal component kind of like the React
equivillent to jQuery's <code>.appendTo()</code>, which is helpful for components that need to be appended to a DOM node other than
Expand All @@ -801,6 +801,15 @@ const ComponentsPage = React.createClass({
<h3 id='utilities-props'>Props</h3>

<PropTable component='Portal'/>

<h2 id='utilities-position'>Position</h2>
<p>
A Component that absolutely positions its child to a <code>target</code> component or DOM node. Useful for creating custom
popups or tooltips. Used by the Overlay Components.
</p>
<h3 id='utilities-props'>Props</h3>

<PropTable component='Position'/>
</div>
</div>

Expand Down Expand Up @@ -845,7 +854,8 @@ const ComponentsPage = React.createClass({
<NavItem href='#glyphicons' key={24}>Glyphicons</NavItem>
<NavItem href='#tables' key={25}>Tables</NavItem>
<NavItem href='#input' key={26}>Input</NavItem>
<NavItem href='#utilities' key={26}>Input</NavItem>

<NavItem href='#utilities' key={26}>Utilities</NavItem>
</Nav>
<a className='back-to-top' href='#top'>
Back to top
Expand Down
102 changes: 102 additions & 0 deletions src/Position.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React, { cloneElement } from 'react';
import domUtils from './utils/domUtils';
import { calcOverlayPosition } from './utils/overlayPositionUtils';
import CustomPropTypes from './utils/CustomPropTypes';

class Position extends React.Component {

constructor(props, context){
super(props, context);
this.state = {
positionLeft: null,
positionTop: null,
arrowOffsetLeft: null,
arrowOffsetTop: null
};
}

componentWillMount(){
this._needsFlush = true;
}

componentWillRecieveProps(){
this._needsFlush = true;
}

componentDidMount(){
this._maybeUpdatePosition();
}
componentDidUpate(){
this._maybeUpdatePosition();
}

render() {
let { placement, children } = this.props;
let { positionLeft, positionTop, ...arrows } = this.props.target ? this.state : {};

return cloneElement(
React.Children.only(children), {
...arrows,
placement,
positionTop,
positionLeft,
style: {
...children.props.style,
left: positionLeft,
top: positionTop
}
}
);
}

_maybeUpdatePosition(){
if ( this._needsFlush ) {
this._needsFlush = false;
this._updatePosition();
}
}

_updatePosition() {
if ( this.props.target == null ){
return;
}

let target = React.findDOMNode(this.props.target(this.props));
let container = React.findDOMNode(this.props.container) || domUtils.ownerDocument(this).body;

this.setState(
calcOverlayPosition(
this.props.placement
, React.findDOMNode(this)
, target
, container
, this.props.containerPadding));
}
}

Position.propTypes = {
/**
* The target DOM node the Component is positioned next too.
*/
target: React.PropTypes.func,
/**
* The "offsetParent" of the Component
*/
container: CustomPropTypes.mountable,
/**
* Distance in pixels the Component should be positioned to the edge of the Container.
*/
containerPadding: React.PropTypes.number,
/**
* The location that the overlay should be positioned to its target.
*/
placement: React.PropTypes.oneOf(['top', 'right', 'bottom', 'left'])
};

Position.defaultProps = {
containerPadding: 0,
placement: 'right'
};


export default Position;
113 changes: 113 additions & 0 deletions src/utils/overlayPositionUtils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import domUtils from './domUtils';

const utils = {

getContainerDimensions(containerNode) {
let width, height, scroll;

if (containerNode.tagName === 'BODY') {
width = window.innerWidth;
height = window.innerHeight;
scroll =
domUtils.ownerDocument(containerNode).documentElement.scrollTop ||
containerNode.scrollTop;
} else {
width = containerNode.offsetWidth;
height = containerNode.offsetHeight;
scroll = containerNode.scrollTop;
}

return {width, height, scroll};
},

getPosition(target, container) {
const offset = container.tagName === 'BODY' ?
domUtils.getOffset(target) : domUtils.getPosition(target, container);

return {
...offset, // eslint-disable-line object-shorthand
height: target.offsetHeight,
width: target.offsetWidth
};
},

calcOverlayPosition(placement, overlayNode, target, container, padding) {
const childOffset = utils.getPosition(target, container);

const overlayHeight = overlayNode.offsetHeight;
const overlayWidth = overlayNode.offsetWidth;

let positionLeft, positionTop, arrowOffsetLeft, arrowOffsetTop;

if (placement === 'left' || placement === 'right') {
positionTop = childOffset.top + (childOffset.height - overlayHeight) / 2;

if (placement === 'left') {
positionLeft = childOffset.left - overlayWidth;
} else {
positionLeft = childOffset.left + childOffset.width;
}

const topDelta = getTopDelta(positionTop, overlayHeight, container, padding);

positionTop += topDelta;
arrowOffsetTop = 50 * (1 - 2 * topDelta / overlayHeight) + '%';
arrowOffsetLeft = null;

} else if (placement === 'top' || placement === 'bottom') {
positionLeft = childOffset.left + (childOffset.width - overlayWidth) / 2;

if (placement === 'top') {
positionTop = childOffset.top - overlayHeight;
} else {
positionTop = childOffset.top + childOffset.height;
}

const leftDelta = getLeftDelta(positionLeft, overlayWidth, container, padding);
positionLeft += leftDelta;
arrowOffsetLeft = 50 * (1 - 2 * leftDelta / overlayWidth) + '%';
arrowOffsetTop = null;
} else {
throw new Error(
`calcOverlayPosition(): No such placement of "${placement }" found.`
);
}

return { positionLeft, positionTop, arrowOffsetLeft, arrowOffsetTop };
}
};


function getTopDelta(top, overlayHeight, container, padding) {
const containerDimensions = utils.getContainerDimensions(container);
const containerScroll = containerDimensions.scroll;
const containerHeight = containerDimensions.height;

const topEdgeOffset = top - padding - containerScroll;
const bottomEdgeOffset = top + padding - containerScroll + overlayHeight;

if (topEdgeOffset < 0) {
return -topEdgeOffset;
} else if (bottomEdgeOffset > containerHeight) {
return containerHeight - bottomEdgeOffset;
} else {
return 0;
}
}

function getLeftDelta(left, overlayWidth, container, padding) {
const containerDimensions = utils.getContainerDimensions(container);
const containerWidth = containerDimensions.width;

const leftEdgeOffset = left - padding;
const rightEdgeOffset = left + padding + overlayWidth;

if (leftEdgeOffset < 0) {
return -leftEdgeOffset;
} else if (rightEdgeOffset > containerWidth) {
return containerWidth - rightEdgeOffset;
} else {
return 0;
}
}
export default utils;
92 changes: 92 additions & 0 deletions test/utils/overlayPositionUtilsSpec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import position from '../../src/utils/overlayPositionUtils';

describe('calcOverlayPosition()', function() {
[
{
placement: 'left',
noOffset: [50, 300, null, '50%'],
offsetBefore: [-200, 150, null, '0%'],
offsetAfter: [300, 450, null, '100%']
},
{
placement: 'top',
noOffset: [200, 150, '50%', null],
offsetBefore: [50, -100, '0%', null],
offsetAfter: [350, 400, '100%', null]
},
{
placement: 'bottom',
noOffset: [200, 450, '50%', null],
offsetBefore: [50, 200, '0%', null],
offsetAfter: [350, 700, '100%', null]
},
{
placement: 'right',
noOffset: [350, 300, null, '50%'],
offsetBefore: [100, 150, null, '0%'],
offsetAfter: [600, 450, null, '100%']
}
].forEach(function(testCase) {

describe(`placement = ${testCase.placement}`, function() {
let overlayStub, padding, placement;

beforeEach(function() {
placement = testCase.placement;
padding = 50;
overlayStub = {
offsetHeight: 200, offsetWidth: 200
};

position.getContainerDimensions = sinon.stub().returns({
width: 600, height: 600, scroll: 100
});
});

function checkPosition(expected) {
const [
positionLeft,
positionTop,
arrowOffsetLeft,
arrowOffsetTop
] = expected;

it('Should calculate the correct position', function() {
position.calcOverlayPosition(placement, overlayStub, {}, {}, padding).should.eql(
{ positionLeft, positionTop, arrowOffsetLeft, arrowOffsetTop }
);
});
}

describe('no viewport offset', function() {
beforeEach(function() {
position.getPosition = sinon.stub().returns({
left: 250, top: 350, width: 100, height: 100
});
});

checkPosition(testCase.noOffset);
});

describe('viewport offset before', function() {
beforeEach(function() {
position.getPosition = sinon.stub().returns({
left: 0, top: 100, width: 100, height: 100
});
});

checkPosition(testCase.offsetBefore);
});

describe('viewport offset after', function() {
beforeEach(function() {
position.getPosition = sinon.stub().returns({
left: 500, top: 600, width: 100, height: 100
});
});

checkPosition(testCase.offsetAfter);
});
});
});
});

0 comments on commit 1638f69

Please sign in to comment.