Skip to content

Commit 1638f69

Browse files
committedJun 30, 2015
[added] Position component for custom Overlays
1 parent f799110 commit 1638f69

File tree

4 files changed

+319
-2
lines changed

4 files changed

+319
-2
lines changed
 

‎docs/src/ComponentsPage.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -792,7 +792,7 @@ const ComponentsPage = React.createClass({
792792
<div className='bs-docs-section'>
793793
<h1 id='utilities' className='page-header'>Utilities <small>Portal</small></h1>
794794

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

803803
<PropTable component='Portal'/>
804+
805+
<h2 id='utilities-position'>Position</h2>
806+
<p>
807+
A Component that absolutely positions its child to a <code>target</code> component or DOM node. Useful for creating custom
808+
popups or tooltips. Used by the Overlay Components.
809+
</p>
810+
<h3 id='utilities-props'>Props</h3>
811+
812+
<PropTable component='Position'/>
804813
</div>
805814
</div>
806815

@@ -845,7 +854,8 @@ const ComponentsPage = React.createClass({
845854
<NavItem href='#glyphicons' key={24}>Glyphicons</NavItem>
846855
<NavItem href='#tables' key={25}>Tables</NavItem>
847856
<NavItem href='#input' key={26}>Input</NavItem>
848-
<NavItem href='#utilities' key={26}>Input</NavItem>
857+
858+
<NavItem href='#utilities' key={26}>Utilities</NavItem>
849859
</Nav>
850860
<a className='back-to-top' href='#top'>
851861
Back to top

‎src/Position.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import React, { cloneElement } from 'react';
2+
import domUtils from './utils/domUtils';
3+
import { calcOverlayPosition } from './utils/overlayPositionUtils';
4+
import CustomPropTypes from './utils/CustomPropTypes';
5+
6+
class Position extends React.Component {
7+
8+
constructor(props, context){
9+
super(props, context);
10+
this.state = {
11+
positionLeft: null,
12+
positionTop: null,
13+
arrowOffsetLeft: null,
14+
arrowOffsetTop: null
15+
};
16+
}
17+
18+
componentWillMount(){
19+
this._needsFlush = true;
20+
}
21+
22+
componentWillRecieveProps(){
23+
this._needsFlush = true;
24+
}
25+
26+
componentDidMount(){
27+
this._maybeUpdatePosition();
28+
}
29+
componentDidUpate(){
30+
this._maybeUpdatePosition();
31+
}
32+
33+
render() {
34+
let { placement, children } = this.props;
35+
let { positionLeft, positionTop, ...arrows } = this.props.target ? this.state : {};
36+
37+
return cloneElement(
38+
React.Children.only(children), {
39+
...arrows,
40+
placement,
41+
positionTop,
42+
positionLeft,
43+
style: {
44+
...children.props.style,
45+
left: positionLeft,
46+
top: positionTop
47+
}
48+
}
49+
);
50+
}
51+
52+
_maybeUpdatePosition(){
53+
if ( this._needsFlush ) {
54+
this._needsFlush = false;
55+
this._updatePosition();
56+
}
57+
}
58+
59+
_updatePosition() {
60+
if ( this.props.target == null ){
61+
return;
62+
}
63+
64+
let target = React.findDOMNode(this.props.target(this.props));
65+
let container = React.findDOMNode(this.props.container) || domUtils.ownerDocument(this).body;
66+
67+
this.setState(
68+
calcOverlayPosition(
69+
this.props.placement
70+
, React.findDOMNode(this)
71+
, target
72+
, container
73+
, this.props.containerPadding));
74+
}
75+
}
76+
77+
Position.propTypes = {
78+
/**
79+
* The target DOM node the Component is positioned next too.
80+
*/
81+
target: React.PropTypes.func,
82+
/**
83+
* The "offsetParent" of the Component
84+
*/
85+
container: CustomPropTypes.mountable,
86+
/**
87+
* Distance in pixels the Component should be positioned to the edge of the Container.
88+
*/
89+
containerPadding: React.PropTypes.number,
90+
/**
91+
* The location that the overlay should be positioned to its target.
92+
*/
93+
placement: React.PropTypes.oneOf(['top', 'right', 'bottom', 'left'])
94+
};
95+
96+
Position.defaultProps = {
97+
containerPadding: 0,
98+
placement: 'right'
99+
};
100+
101+
102+
export default Position;

‎src/utils/overlayPositionUtils.js

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import domUtils from './domUtils';
2+
3+
const utils = {
4+
5+
getContainerDimensions(containerNode) {
6+
let width, height, scroll;
7+
8+
if (containerNode.tagName === 'BODY') {
9+
width = window.innerWidth;
10+
height = window.innerHeight;
11+
scroll =
12+
domUtils.ownerDocument(containerNode).documentElement.scrollTop ||
13+
containerNode.scrollTop;
14+
} else {
15+
width = containerNode.offsetWidth;
16+
height = containerNode.offsetHeight;
17+
scroll = containerNode.scrollTop;
18+
}
19+
20+
return {width, height, scroll};
21+
},
22+
23+
getPosition(target, container) {
24+
const offset = container.tagName === 'BODY' ?
25+
domUtils.getOffset(target) : domUtils.getPosition(target, container);
26+
27+
return {
28+
...offset, // eslint-disable-line object-shorthand
29+
height: target.offsetHeight,
30+
width: target.offsetWidth
31+
};
32+
},
33+
34+
calcOverlayPosition(placement, overlayNode, target, container, padding) {
35+
const childOffset = utils.getPosition(target, container);
36+
37+
const overlayHeight = overlayNode.offsetHeight;
38+
const overlayWidth = overlayNode.offsetWidth;
39+
40+
let positionLeft, positionTop, arrowOffsetLeft, arrowOffsetTop;
41+
42+
if (placement === 'left' || placement === 'right') {
43+
positionTop = childOffset.top + (childOffset.height - overlayHeight) / 2;
44+
45+
if (placement === 'left') {
46+
positionLeft = childOffset.left - overlayWidth;
47+
} else {
48+
positionLeft = childOffset.left + childOffset.width;
49+
}
50+
51+
const topDelta = getTopDelta(positionTop, overlayHeight, container, padding);
52+
53+
positionTop += topDelta;
54+
arrowOffsetTop = 50 * (1 - 2 * topDelta / overlayHeight) + '%';
55+
arrowOffsetLeft = null;
56+
57+
} else if (placement === 'top' || placement === 'bottom') {
58+
positionLeft = childOffset.left + (childOffset.width - overlayWidth) / 2;
59+
60+
if (placement === 'top') {
61+
positionTop = childOffset.top - overlayHeight;
62+
} else {
63+
positionTop = childOffset.top + childOffset.height;
64+
}
65+
66+
const leftDelta = getLeftDelta(positionLeft, overlayWidth, container, padding);
67+
positionLeft += leftDelta;
68+
arrowOffsetLeft = 50 * (1 - 2 * leftDelta / overlayWidth) + '%';
69+
arrowOffsetTop = null;
70+
} else {
71+
throw new Error(
72+
`calcOverlayPosition(): No such placement of "${placement }" found.`
73+
);
74+
}
75+
76+
return { positionLeft, positionTop, arrowOffsetLeft, arrowOffsetTop };
77+
}
78+
};
79+
80+
81+
function getTopDelta(top, overlayHeight, container, padding) {
82+
const containerDimensions = utils.getContainerDimensions(container);
83+
const containerScroll = containerDimensions.scroll;
84+
const containerHeight = containerDimensions.height;
85+
86+
const topEdgeOffset = top - padding - containerScroll;
87+
const bottomEdgeOffset = top + padding - containerScroll + overlayHeight;
88+
89+
if (topEdgeOffset < 0) {
90+
return -topEdgeOffset;
91+
} else if (bottomEdgeOffset > containerHeight) {
92+
return containerHeight - bottomEdgeOffset;
93+
} else {
94+
return 0;
95+
}
96+
}
97+
98+
function getLeftDelta(left, overlayWidth, container, padding) {
99+
const containerDimensions = utils.getContainerDimensions(container);
100+
const containerWidth = containerDimensions.width;
101+
102+
const leftEdgeOffset = left - padding;
103+
const rightEdgeOffset = left + padding + overlayWidth;
104+
105+
if (leftEdgeOffset < 0) {
106+
return -leftEdgeOffset;
107+
} else if (rightEdgeOffset > containerWidth) {
108+
return containerWidth - rightEdgeOffset;
109+
} else {
110+
return 0;
111+
}
112+
}
113+
export default utils;
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import position from '../../src/utils/overlayPositionUtils';
2+
3+
describe('calcOverlayPosition()', function() {
4+
[
5+
{
6+
placement: 'left',
7+
noOffset: [50, 300, null, '50%'],
8+
offsetBefore: [-200, 150, null, '0%'],
9+
offsetAfter: [300, 450, null, '100%']
10+
},
11+
{
12+
placement: 'top',
13+
noOffset: [200, 150, '50%', null],
14+
offsetBefore: [50, -100, '0%', null],
15+
offsetAfter: [350, 400, '100%', null]
16+
},
17+
{
18+
placement: 'bottom',
19+
noOffset: [200, 450, '50%', null],
20+
offsetBefore: [50, 200, '0%', null],
21+
offsetAfter: [350, 700, '100%', null]
22+
},
23+
{
24+
placement: 'right',
25+
noOffset: [350, 300, null, '50%'],
26+
offsetBefore: [100, 150, null, '0%'],
27+
offsetAfter: [600, 450, null, '100%']
28+
}
29+
].forEach(function(testCase) {
30+
31+
describe(`placement = ${testCase.placement}`, function() {
32+
let overlayStub, padding, placement;
33+
34+
beforeEach(function() {
35+
placement = testCase.placement;
36+
padding = 50;
37+
overlayStub = {
38+
offsetHeight: 200, offsetWidth: 200
39+
};
40+
41+
position.getContainerDimensions = sinon.stub().returns({
42+
width: 600, height: 600, scroll: 100
43+
});
44+
});
45+
46+
function checkPosition(expected) {
47+
const [
48+
positionLeft,
49+
positionTop,
50+
arrowOffsetLeft,
51+
arrowOffsetTop
52+
] = expected;
53+
54+
it('Should calculate the correct position', function() {
55+
position.calcOverlayPosition(placement, overlayStub, {}, {}, padding).should.eql(
56+
{ positionLeft, positionTop, arrowOffsetLeft, arrowOffsetTop }
57+
);
58+
});
59+
}
60+
61+
describe('no viewport offset', function() {
62+
beforeEach(function() {
63+
position.getPosition = sinon.stub().returns({
64+
left: 250, top: 350, width: 100, height: 100
65+
});
66+
});
67+
68+
checkPosition(testCase.noOffset);
69+
});
70+
71+
describe('viewport offset before', function() {
72+
beforeEach(function() {
73+
position.getPosition = sinon.stub().returns({
74+
left: 0, top: 100, width: 100, height: 100
75+
});
76+
});
77+
78+
checkPosition(testCase.offsetBefore);
79+
});
80+
81+
describe('viewport offset after', function() {
82+
beforeEach(function() {
83+
position.getPosition = sinon.stub().returns({
84+
left: 500, top: 600, width: 100, height: 100
85+
});
86+
});
87+
88+
checkPosition(testCase.offsetAfter);
89+
});
90+
});
91+
});
92+
});

0 commit comments

Comments
 (0)