From ebb60ccd538bed1f5b4de68e9c47abe60234e4e7 Mon Sep 17 00:00:00 2001 From: Jimmy Jia Date: Tue, 12 Jul 2016 15:48:50 -0400 Subject: [PATCH] Make `target` work like `container` --- src/Position.js | 82 +++++++++++----------- src/utils/calculatePosition.js | 110 +++++++++++++++++++++++++++++ src/utils/overlayPositionUtils.js | 111 ------------------------------ test/PositionSpec.js | 17 +++-- 4 files changed, 158 insertions(+), 162 deletions(-) create mode 100644 src/utils/calculatePosition.js delete mode 100644 src/utils/overlayPositionUtils.js diff --git a/src/Position.js b/src/Position.js index b1094b7c..afc42100 100644 --- a/src/Position.js +++ b/src/Position.js @@ -1,20 +1,20 @@ +import classNames from 'classnames'; import React, { cloneElement } from 'react'; import ReactDOM from 'react-dom'; -import classNames from 'classnames'; -import ownerDocument from './utils/ownerDocument'; -import getContainer from './utils/getContainer'; - -import { calcOverlayPosition } from './utils/overlayPositionUtils'; - import mountable from 'react-prop-types/lib/mountable'; +import calculatePosition from './utils/calculatePosition'; +import getContainer from './utils/getContainer'; +import ownerDocument from './utils/ownerDocument'; + /** - * The Position component calculates the coordinates for its child, to - * position it relative to a `target` component or node. Useful for creating callouts and tooltips, - * the Position component injects a `style` props with `left` and `top` values for positioning your component. + * The Position component calculates the coordinates for its child, to position + * it relative to a `target` component or node. Useful for creating callouts + * and tooltips, the Position component injects a `style` props with `left` and + * `top` values for positioning your component. * - * It also injects "arrow" `left`, and `top` values for styling callout arrows for giving your components - * a sense of directionality. + * It also injects "arrow" `left`, and `top` values for styling callout arrows + * for giving your components a sense of directionality. */ class Position extends React.Component { constructor(props, context) { @@ -32,7 +32,7 @@ class Position extends React.Component { } componentDidMount() { - this.updatePosition(); + this.updatePosition(this.getTarget()); } componentWillReceiveProps() { @@ -42,16 +42,10 @@ class Position extends React.Component { componentDidUpdate(prevProps) { if (this._needsFlush) { this._needsFlush = false; - this.updatePosition(prevProps.placement !== this.props.placement); + this.maybeUpdatePosition(this.props.placement !== prevProps.placement); } } - componentWillUnmount() { - // Probably not necessary, but just in case holding a reference to the - // target causes problems somewhere. - this._lastTarget = null; - } - render() { const {children, className, ...props} = this.props; const {positionLeft, positionTop, ...arrowPosition} = this.state; @@ -68,7 +62,8 @@ class Position extends React.Component { { ...props, ...arrowPosition, - //do we need to also forward positionLeft and positionTop if they are set to style? + // FIXME: Don't forward `positionLeft` and `positionTop` via both props + // and `props.style`. positionLeft, positionTop, className: classNames(className, child.props.className), @@ -81,27 +76,27 @@ class Position extends React.Component { ); } - getTargetSafe() { - if (!this.props.target) { - return null; - } - - const target = this.props.target(this.props); - if (!target) { - // This is so we can just use === check below on all falsy targets. - return null; - } - - return target; + getTarget() { + const { target } = this.props; + const targetElement = typeof target === 'function' ? target() : target; + return targetElement && ReactDOM.findDOMNode(targetElement) || null; } - updatePosition(placementChanged) { - const target = this.getTargetSafe(); + maybeUpdatePosition(placementChanged) { + const target = this.getTarget(); - if (!this.props.shouldUpdatePosition && target === this._lastTarget && !placementChanged) { + if ( + !this.props.shouldUpdatePosition && + target === this._lastTarget && + !placementChanged + ) { return; } + this.updatePosition(target); + } + + updatePosition(target) { this._lastTarget = target; if (!target) { @@ -116,9 +111,11 @@ class Position extends React.Component { } const overlay = ReactDOM.findDOMNode(this); - const container = getContainer(this.props.container, ownerDocument(this).body); + const container = getContainer( + this.props.container, ownerDocument(this).body + ); - this.setState(calcOverlayPosition( + this.setState(calculatePosition( this.props.placement, overlay, target, @@ -130,17 +127,18 @@ class Position extends React.Component { Position.propTypes = { /** - * Function mapping props to a DOM node the component is positioned next to - * + * A node, element, or function that returns either. The child will be + * be positioned next to the `target` specified. */ - target: React.PropTypes.func, + target: React.PropTypes.oneOfType([ + mountable, React.PropTypes.func + ]), /** * "offsetParent" of the component */ container: React.PropTypes.oneOfType([ - mountable, - React.PropTypes.func + mountable, React.PropTypes.func ]), /** * Minimum spacing in pixels between container border and component border diff --git a/src/utils/calculatePosition.js b/src/utils/calculatePosition.js new file mode 100644 index 00000000..c89a78f9 --- /dev/null +++ b/src/utils/calculatePosition.js @@ -0,0 +1,110 @@ +import getOffset from 'dom-helpers/query/offset'; +import getPosition from 'dom-helpers/query/position'; +import getScrollTop from 'dom-helpers/query/scrollTop'; + +import ownerDocument from './ownerDocument'; + +function getContainerDimensions(containerNode) { + let width, height, scroll; + + if (containerNode.tagName === 'BODY') { + width = window.innerWidth; + height = window.innerHeight; + + scroll = + getScrollTop(ownerDocument(containerNode).documentElement) || + getScrollTop(containerNode); + } else { + ({ width, height } = getOffset(containerNode)); + scroll = getScrollTop(containerNode); + } + + return { width, height, scroll}; +} + +function getTopDelta(top, overlayHeight, container, padding) { + const containerDimensions = 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 = 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; + } + + return 0; +} + +export default function calculatePosition( + placement, overlayNode, target, container, padding +) { + const childOffset = container.tagName === 'BODY' ? + getOffset(target) : getPosition(target, container); + + const { height: overlayHeight, width: overlayWidth } = + getOffset(overlayNode); + + 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 = void 0; + + } 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 = void 0; + + } else { + throw new Error( + `calcOverlayPosition(): No such placement of "${placement}" found.` + ); + } + + return { positionLeft, positionTop, arrowOffsetLeft, arrowOffsetTop }; +} diff --git a/src/utils/overlayPositionUtils.js b/src/utils/overlayPositionUtils.js deleted file mode 100644 index 6042588e..00000000 --- a/src/utils/overlayPositionUtils.js +++ /dev/null @@ -1,111 +0,0 @@ -import ownerDocument from './ownerDocument'; -import getOffset from 'dom-helpers/query/offset'; -import getPosition from 'dom-helpers/query/position'; -import getScrollTop from 'dom-helpers/query/scrollTop'; - -const utils = { - - getContainerDimensions(containerNode) { - let width, height, scroll; - - if (containerNode.tagName === 'BODY') { - width = window.innerWidth; - height = window.innerHeight; - - scroll = - getScrollTop(ownerDocument(containerNode).documentElement) || - getScrollTop(containerNode); - } else { - ({ width, height } = getOffset(containerNode)); - scroll = getScrollTop(containerNode); - } - - return { width, height, scroll}; - }, - - getPosition(target, container) { - const offset = container.tagName === 'BODY' ? - getOffset(target) : getPosition(target, container); - - return offset; - }, - - calcOverlayPosition(placement, overlayNode, target, container, padding) { - const childOffset = utils.getPosition(target, container); - - const { height: overlayHeight, width: overlayWidth } = getOffset(overlayNode); - - 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 = void 0; - - } 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 = void 0; - } 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/PositionSpec.js b/test/PositionSpec.js index 6a83f0cd..fb3ec6de 100644 --- a/test/PositionSpec.js +++ b/test/PositionSpec.js @@ -4,9 +4,8 @@ import ReactDOM from 'react-dom'; import ReactTestUtils from 'react-addons-test-utils'; import Position from '../src/Position'; -import overlayPositionUtils from '../src/utils/overlayPositionUtils'; -import {render} from './helpers'; +import { render } from './helpers'; describe('Position', function () { // Swallow extra props. @@ -34,13 +33,13 @@ describe('Position', function () { describe('position recalculation', function () { beforeEach(function () { - sinon.spy(overlayPositionUtils, 'calcOverlayPosition'); sinon.spy(Position.prototype, 'componentWillReceiveProps'); + sinon.spy(Position.prototype, 'updatePosition'); }); afterEach(function () { - overlayPositionUtils.calcOverlayPosition.restore(); Position.prototype.componentWillReceiveProps.restore(); + Position.prototype.updatePosition.restore(); }); it('Should only recalculate when target changes', function () { @@ -76,7 +75,7 @@ describe('Position', function () { // Position calculates initial position. expect(Position.prototype.componentWillReceiveProps) .to.have.not.been.called; - expect(overlayPositionUtils.calcOverlayPosition) + expect(Position.prototype.updatePosition) .to.have.been.calledOnce; instance.setState({target: 'bar'}); @@ -84,7 +83,7 @@ describe('Position', function () { // Position receives new props and recalculates position. expect(Position.prototype.componentWillReceiveProps) .to.have.been.calledOnce; - expect(overlayPositionUtils.calcOverlayPosition) + expect(Position.prototype.updatePosition) .to.have.been.calledTwice; instance.setState({fakeProp: 1}); @@ -92,7 +91,7 @@ describe('Position', function () { // Position receives new props but should not recalculate position. expect(Position.prototype.componentWillReceiveProps) .to.have.been.calledTwice; - expect(overlayPositionUtils.calcOverlayPosition) + expect(Position.prototype.updatePosition) .to.have.been.calledTwice; }); @@ -129,7 +128,7 @@ describe('Position', function () { // Position calculates initial position. expect(Position.prototype.componentWillReceiveProps) .to.have.not.been.called; - expect(overlayPositionUtils.calcOverlayPosition) + expect(Position.prototype.updatePosition) .to.have.been.calledOnce; instance.setState({fakeProp: 1}); @@ -137,7 +136,7 @@ describe('Position', function () { // Position receives new props and position should be recalculated expect(Position.prototype.componentWillReceiveProps) .to.have.been.calledOnce; - expect(overlayPositionUtils.calcOverlayPosition) + expect(Position.prototype.updatePosition) .to.have.been.calledTwice; }); });