diff --git a/docs/src/ComponentsPage.js b/docs/src/ComponentsPage.js index 3c37d256b8..a04a1097c7 100644 --- a/docs/src/ComponentsPage.js +++ b/docs/src/ComponentsPage.js @@ -792,7 +792,7 @@ const ComponentsPage = React.createClass({

Utilities Portal

-

Portal

+

Portal

A Component that renders its children into a new React "subtree" or container. The Portal component kind of like the React equivillent to jQuery's .appendTo(), which is helpful for components that need to be appended to a DOM node other than @@ -801,6 +801,15 @@ const ComponentsPage = React.createClass({

Props

+ +

Position

+

+ A Component that absolutely positions its child to a target component or DOM node. Useful for creating custom + popups or tooltips. Used by the Overlay Components. +

+

Props

+ +
@@ -845,7 +854,8 @@ const ComponentsPage = React.createClass({ Glyphicons Tables Input - Input + + Utilities Back to top diff --git a/src/Position.js b/src/Position.js new file mode 100644 index 0000000000..b92c256965 --- /dev/null +++ b/src/Position.js @@ -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; diff --git a/src/utils/overlayPositionUtils.js b/src/utils/overlayPositionUtils.js new file mode 100644 index 0000000000..eb4a0459ec --- /dev/null +++ b/src/utils/overlayPositionUtils.js @@ -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; diff --git a/test/utils/overlayPositionUtilsSpec.js b/test/utils/overlayPositionUtilsSpec.js new file mode 100644 index 0000000000..50703e4b4c --- /dev/null +++ b/test/utils/overlayPositionUtilsSpec.js @@ -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); + }); + }); + }); + });