diff --git a/docs/assets/style.css b/docs/assets/style.css
index ca91998e74..1d1d2fbf2f 100644
--- a/docs/assets/style.css
+++ b/docs/assets/style.css
@@ -98,12 +98,20 @@ body {
height: 200px;
}
-.bs-example-scroll {
+.bs-example-popover-contained {
+ height: 200px;
+}
+
+.bs-example-popover-contained > div {
+ position: relative;
+}
+
+.bs-example-popover-scroll {
overflow: scroll;
height: 200px;
}
-.bs-example-scroll > div {
+.bs-example-popover-scroll > div {
position: relative;
padding: 100px 0;
}
diff --git a/docs/examples/PopoverContained.js b/docs/examples/PopoverContained.js
new file mode 100644
index 0000000000..afd3019e3e
--- /dev/null
+++ b/docs/examples/PopoverContained.js
@@ -0,0 +1,13 @@
+const positionerInstance = (
+
+ Holy guacamole! Check this info.}
+ >
+
+
+
+);
+
+React.render(positionerInstance, mountNode);
diff --git a/docs/examples/PopoverPositionedContained.js b/docs/examples/PopoverPositionedScrolling.js
similarity index 100%
rename from docs/examples/PopoverPositionedContained.js
rename to docs/examples/PopoverPositionedScrolling.js
diff --git a/docs/src/ComponentsPage.js b/docs/src/ComponentsPage.js
index 4dc335759a..f3e0452923 100644
--- a/docs/src/ComponentsPage.js
+++ b/docs/src/ComponentsPage.js
@@ -258,14 +258,17 @@ const ComponentsPage = React.createClass({
Popovers Popover
Example popovers
- Popovers component.
+ Popover component.
- Popovers component.
+ Positioned popover component.
- Popovers scrolling.
-
+ Popover component in container.
+
+
+ Positioned popover components in scrolling container.
+
{/* Progress Bar */}
diff --git a/docs/src/Samples.js b/docs/src/Samples.js
index d4821d9d10..62d4e1439c 100644
--- a/docs/src/Samples.js
+++ b/docs/src/Samples.js
@@ -38,7 +38,8 @@ export default {
TooltipInCopy: require('fs').readFileSync(__dirname + '/../examples/TooltipInCopy.js', 'utf8'),
PopoverBasic: require('fs').readFileSync(__dirname + '/../examples/PopoverBasic.js', 'utf8'),
PopoverPositioned: require('fs').readFileSync(__dirname + '/../examples/PopoverPositioned.js', 'utf8'),
- PopoverPositionedContained: require('fs').readFileSync(__dirname + '/../examples/PopoverPositionedContained.js', 'utf8'),
+ PopoverContained: require('fs').readFileSync(__dirname + '/../examples/PopoverContained.js', 'utf8'),
+ PopoverPositionedScrolling: require('fs').readFileSync(__dirname + '/../examples/PopoverPositionedScrolling.js', 'utf8'),
ProgressBarBasic: require('fs').readFileSync(__dirname + '/../examples/ProgressBarBasic.js', 'utf8'),
ProgressBarWithLabel: require('fs').readFileSync(__dirname + '/../examples/ProgressBarWithLabel.js', 'utf8'),
ProgressBarScreenreaderLabel: require('fs').readFileSync(__dirname + '/../examples/ProgressBarScreenreaderLabel.js', 'utf8'),
diff --git a/src/OverlayTrigger.js b/src/OverlayTrigger.js
index b0d70a1bc7..4a0e695bd5 100644
--- a/src/OverlayTrigger.js
+++ b/src/OverlayTrigger.js
@@ -33,13 +33,15 @@ const OverlayTrigger = React.createClass({
delayShow: React.PropTypes.number,
delayHide: React.PropTypes.number,
defaultOverlayShown: React.PropTypes.bool,
- overlay: React.PropTypes.node.isRequired
+ overlay: React.PropTypes.node.isRequired,
+ containerPadding: React.PropTypes.number
},
getDefaultProps() {
return {
placement: 'right',
- trigger: ['hover', 'focus']
+ trigger: ['hover', 'focus'],
+ containerPadding: 0
};
},
@@ -48,7 +50,9 @@ const OverlayTrigger = React.createClass({
isOverlayShown: this.props.defaultOverlayShown == null ?
false : this.props.defaultOverlayShown,
overlayLeft: null,
- overlayTop: null
+ overlayTop: null,
+ arrowOffsetLeft: null,
+ arrowOffsetTop: null
};
},
@@ -85,18 +89,20 @@ const OverlayTrigger = React.createClass({
onRequestHide: this.hide,
placement: this.props.placement,
positionLeft: this.state.overlayLeft,
- positionTop: this.state.overlayTop
+ positionTop: this.state.overlayTop,
+ arrowOffsetLeft: this.state.arrowOffsetLeft,
+ arrowOffsetTop: this.state.arrowOffsetTop
}
);
},
render() {
- let child = React.Children.only(this.props.children);
+ const child = React.Children.only(this.props.children);
if (this.props.trigger === 'manual') {
return child;
}
- let props = {};
+ const props = {};
props.onClick = createChainedFunction(child.props.onClick, this.props.onClick);
if (isOneOf('click', this.props.trigger)) {
@@ -136,7 +142,7 @@ const OverlayTrigger = React.createClass({
return;
}
- let delay = this.props.delayShow != null ?
+ const delay = this.props.delayShow != null ?
this.props.delayShow : this.props.delay;
if (!delay) {
@@ -157,7 +163,7 @@ const OverlayTrigger = React.createClass({
return;
}
- let delay = this.props.delayHide != null ?
+ const delay = this.props.delayHide != null ?
this.props.delayHide : this.props.delay;
if (!delay) {
@@ -176,52 +182,112 @@ const OverlayTrigger = React.createClass({
return;
}
- let pos = this.calcOverlayPosition();
-
- this.setState({
- overlayLeft: pos.left,
- overlayTop: pos.top
- });
+ this.setState(this.calcOverlayPosition());
},
calcOverlayPosition() {
- let childOffset = this.getPosition();
-
- let overlayNode = this.getOverlayDOMNode();
- let overlayHeight = overlayNode.offsetHeight;
- let overlayWidth = overlayNode.offsetWidth;
-
- switch (this.props.placement) {
- case 'right':
- return {
- top: childOffset.top + childOffset.height / 2 - overlayHeight / 2,
- left: childOffset.left + childOffset.width
- };
- case 'left':
- return {
- top: childOffset.top + childOffset.height / 2 - overlayHeight / 2,
- left: childOffset.left - overlayWidth
- };
- case 'top':
- return {
- top: childOffset.top - overlayHeight,
- left: childOffset.left + childOffset.width / 2 - overlayWidth / 2
- };
- case 'bottom':
- return {
- top: childOffset.top + childOffset.height,
- left: childOffset.left + childOffset.width / 2 - overlayWidth / 2
- };
- default:
- throw new Error('calcOverlayPosition(): No such placement of "' + this.props.placement + '" found.');
+ const childOffset = this.getPosition();
+
+ const overlayNode = this.getOverlayDOMNode();
+ const overlayHeight = overlayNode.offsetHeight;
+ const overlayWidth = overlayNode.offsetWidth;
+
+ const placement = this.props.placement;
+ let overlayLeft, overlayTop, arrowOffsetLeft, arrowOffsetTop;
+
+ if (placement === 'left' || placement === 'right') {
+ overlayTop = childOffset.top + (childOffset.height - overlayHeight) / 2;
+
+ if (placement === 'left') {
+ overlayLeft = childOffset.left - overlayWidth;
+ } else {
+ overlayLeft = childOffset.left + childOffset.width;
+ }
+
+ const topDelta = this._getTopDelta(overlayTop, overlayHeight);
+ overlayTop += topDelta;
+ arrowOffsetTop = 50 * (1 - 2 * topDelta / overlayHeight) + '%';
+ arrowOffsetLeft = null;
+ } else if (placement === 'top' || placement === 'bottom') {
+ overlayLeft = childOffset.left + (childOffset.width - overlayWidth) / 2;
+
+ if (placement === 'top') {
+ overlayTop = childOffset.top - overlayHeight;
+ } else {
+ overlayTop = childOffset.top + childOffset.height;
+ }
+
+ const leftDelta = this._getLeftDelta(overlayLeft, overlayWidth);
+ overlayLeft += leftDelta;
+ arrowOffsetLeft = 50 * (1 - 2 * leftDelta / overlayWidth) + '%';
+ arrowOffsetTop = null;
+ } else {
+ throw new Error(
+ 'calcOverlayPosition(): No such placement of "' +
+ this.props.placement + '" found.'
+ );
+ }
+
+ return {overlayLeft, overlayTop, arrowOffsetLeft, arrowOffsetTop};
+ },
+
+ _getTopDelta(top, overlayHeight) {
+ const containerDimensions = this._getContainerDimensions();
+ const containerScroll = containerDimensions.scroll;
+ const containerHeight = containerDimensions.height;
+
+ const padding = this.props.containerPadding;
+ 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;
+ }
+ },
+
+ _getLeftDelta(left, overlayWidth) {
+ const containerDimensions = this._getContainerDimensions();
+ const containerWidth = containerDimensions.width;
+
+ const padding = this.props.containerPadding;
+ const leftEdgeOffset = left - padding;
+ const rightEdgeOffset = left + padding + overlayWidth;
+
+ if (leftEdgeOffset < 0) {
+ return -leftEdgeOffset;
+ } else if (rightEdgeOffset > containerWidth) {
+ return containerWidth - rightEdgeOffset;
+ } else {
+ return 0;
+ }
+ },
+
+ _getContainerDimensions() {
+ const containerNode = this.getContainerDOMNode();
+ let width, height;
+ if (containerNode.tagName === 'BODY') {
+ width = window.innerWidth;
+ height = window.innerHeight;
+ } else {
+ width = containerNode.offsetWidth;
+ height = containerNode.offsetHeight;
}
+
+ return {
+ width, height,
+ scroll: containerNode.scrollTop
+ };
},
getPosition() {
- let node = React.findDOMNode(this);
- let container = this.getContainerDOMNode();
+ const node = React.findDOMNode(this);
+ const container = this.getContainerDOMNode();
- let offset = container.tagName === 'BODY' ?
+ const offset = container.tagName === 'BODY' ?
domUtils.getOffset(node) : domUtils.getPosition(node, container);
return assign({}, offset, {
diff --git a/test/OverlayTriggerSpec.js b/test/OverlayTriggerSpec.js
index dddf7d6031..dce0753f07 100644
--- a/test/OverlayTriggerSpec.js
+++ b/test/OverlayTriggerSpec.js
@@ -64,4 +64,101 @@ describe('OverlayTrigger', function() {
contextSpy.calledWith('value').should.be.true;
});
+
+ 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 instance;
+
+ beforeEach(function() {
+ instance = ReactTestUtils.renderIntoDocument(
+ test}
+ >
+
+
+ );
+
+ instance.getOverlayDOMNode = sinon.stub().returns({
+ offsetHeight: 200, offsetWidth: 200
+ });
+ instance._getContainerDimensions = sinon.stub().returns({
+ width: 600, height: 600, scroll: 100
+ });
+ });
+
+ function checkPosition(expected) {
+ const [
+ overlayLeft,
+ overlayTop,
+ arrowOffsetLeft,
+ arrowOffsetTop
+ ] = expected;
+
+ it('Should calculate the correct position', function() {
+ instance.calcOverlayPosition().should.eql(
+ {overlayLeft, overlayTop, arrowOffsetLeft, arrowOffsetTop}
+ );
+ });
+ }
+
+ describe('no viewport offset', function() {
+ beforeEach(function() {
+ instance.getPosition = sinon.stub().returns({
+ left: 250, top: 350, width: 100, height: 100
+ });
+ });
+
+ checkPosition(testCase.noOffset);
+ });
+
+ describe('viewport offset before', function() {
+ beforeEach(function() {
+ instance.getPosition = sinon.stub().returns({
+ left: 0, top: 100, width: 100, height: 100
+ });
+ });
+
+ checkPosition(testCase.offsetBefore);
+ });
+
+ describe('viewport offset after', function() {
+ beforeEach(function() {
+ instance.getPosition = sinon.stub().returns({
+ left: 500, top: 600, width: 100, height: 100
+ });
+ });
+
+ checkPosition(testCase.offsetAfter);
+ });
+ });
+ });
+ });
});