instacart/Snacks

View on GitHub
src/components/Tooltip/TooltipPosition.js

Summary

Maintainability
A
2 hrs
Test Coverage
import React, { PureComponent, cloneElement } from 'react'
import { findDOMNode } from 'react-dom'
import PropTypes from 'prop-types'

const styles = {
  root: {
    position: 'absolute',
    zIndex: 99999,
  },
}

const SPACING = 8
const ARROW_SPACING = 6
const ARROW_BORDER_SPACING = 2

class TooltipPosition extends PureComponent {
  static propTypes = {
    children: PropTypes.node.isRequired,
    target: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
    placement: PropTypes.oneOf(['top', 'left', 'right', 'bottom']).isRequired,
    style: PropTypes.shape({}),
    delayCalculatePosition: PropTypes.bool,
  }

  state = {
    overlayRect: {},
    arrowPosition: {},
  }

  componentDidMount() {
    const { delayCalculatePosition } = this.props

    if (delayCalculatePosition) {
      // modified July 28, 2021. Need to delay calculatePosition function because documentWidth, overlay and targetRect may not be ready with the right sizes
      // if we calculatePosition with these wrong sizes, we can't recalculate to the correct one in the next few frames, due to the side effects of calculatePosition
      // (overlayRect values already set)
      setTimeout(() => {
        this.calculatePosition()
      }, 300)
    } else {
      this.calculatePosition()
    }
  }

  getTarget = () => {
    const { target } = this.props
    const targetNode = typeof target === 'function' ? target() : target
    return (targetNode && findDOMNode(targetNode)) || null
  }

  getRect(node) {
    const container = node.getBoundingClientRect()
    const offset = {
      top: container.top + window.pageYOffset,
      left: container.left + window.pageXOffset,
      width: container.width || node.offsetWidth,
      height: container.height || node.offsetHeight,
    }

    return offset
  }

  calculatePosition() {
    const { placement } = this.props
    const target = this.getTarget()
    const { overlay } = this
    const targetRect = this.getRect(target)
    const documentWidth = document.documentElement.offsetWidth

    const overlayRect = {
      top: targetRect.top,
      left: targetRect.left,
      width: overlay.offsetWidth,
      height: overlay.offsetHeight,
    }

    const arrowPosition = {
      top: 0,
      left: 0,
      placement,
    }

    if (['top', 'bottom'].includes(placement)) {
      // Center it
      const targetCenterX = targetRect.width / 2
      overlayRect.left = targetRect.left + targetCenterX - overlayRect.width / 2
      const overlayDistanceFromRightEdge = overlayRect.left + overlayRect.width

      if (placement === 'top') {
        overlayRect.top = targetRect.top - overlayRect.height - SPACING * 2
        arrowPosition.top = Math.ceil(overlayRect.height) - ARROW_SPACING - ARROW_BORDER_SPACING / 2
      } else {
        overlayRect.top = targetRect.top + targetRect.height + SPACING * 2
        arrowPosition.top = -ARROW_SPACING
      }

      if (overlayRect.left < 0) {
        // if over left edge of screen shift right to 8px of edge
        // or inline with target (if target is less than 8px from edge)
        overlayRect.left = Math.min(SPACING, targetRect.left)
      } else if (overlayDistanceFromRightEdge > documentWidth) {
        // If over right edge of screen shift left to 8px of edge or inline with target
        const overRightAmount = overlayDistanceFromRightEdge - documentWidth
        const targetDistanceFromRight = documentWidth - (targetRect.left + targetRect.width)

        overlayRect.left =
          overlayRect.left - overRightAmount - Math.min(SPACING, targetDistanceFromRight)
      }

      arrowPosition.left =
        targetRect.left -
        overlayRect.left +
        targetCenterX -
        ARROW_SPACING -
        ARROW_BORDER_SPACING / 2 // eslint-disable-line max-len
    } else {
      const targetCenterY = targetRect.height / 2
      const overlayCenterY = overlayRect.height / 2

      overlayRect.top = targetRect.top + targetCenterY - overlayCenterY
      arrowPosition.top = overlayCenterY - ARROW_SPACING - ARROW_BORDER_SPACING / 2

      if (placement === 'right') {
        overlayRect.left = targetRect.left + targetRect.width + SPACING * 2
        arrowPosition.left = -ARROW_SPACING
      } else {
        overlayRect.left = targetRect.left - overlayRect.width - SPACING * 2
        arrowPosition.left = overlayRect.width - ARROW_SPACING - ARROW_BORDER_SPACING / 2
      }
    }

    this.setState({ overlayRect, arrowPosition })
  }

  render() {
    const { children, placement, style } = this.props
    const { overlayRect } = this.state
    let computedStyles = styles.root

    if (overlayRect.top) {
      computedStyles = {
        ...computedStyles,
        ...style,
        top: overlayRect.top,
        left: overlayRect.left,
      }
    }

    let child = React.Children.only(children)

    child = cloneElement(child, {
      arrowPosition: this.state.arrowPosition,
      placement,
    })

    return (
      <div style={computedStyles} ref={node => (this.overlay = node)}>
        {child}
      </div>
    )
  }
}

export default TooltipPosition