From 03211dbb3d780a8b3f0ea588ae25ebe96b55e358 Mon Sep 17 00:00:00 2001 From: Jimmy Jia Date: Wed, 13 May 2015 19:20:12 -0400 Subject: [PATCH] [fixed] Fit overlay within viewport boundary For react-bootstrap/react-bootstrap#173 --- docs/assets/style.css | 12 +- docs/examples/PopoverContained.js | 13 ++ ...ained.js => PopoverPositionedScrolling.js} | 0 docs/src/ComponentsPage.js | 11 +- docs/src/Samples.js | 3 +- src/OverlayTrigger.js | 158 +++++++++++++----- test/OverlayTriggerSpec.js | 97 +++++++++++ 7 files changed, 241 insertions(+), 53 deletions(-) create mode 100644 docs/examples/PopoverContained.js rename docs/examples/{PopoverPositionedContained.js => PopoverPositionedScrolling.js} (100%) 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); + }); + }); + }); + }); });