src/components/PositionProvider.js
import React from 'react';
import ToolTipArrow from './ToolTipArrow';
import { DEFAULT_ARROW_MARGIN, POSITION, SIZE, BOUNDARY, CLASSES } from '../constants';
const exceedsRightBound = (left, elementRect, scrollLeft, boundary = BOUNDARY) => {
const bodyRect = document.body.getBoundingClientRect();
return left + elementRect.width >= scrollLeft + bodyRect.width + (-boundary);
};
const exceedsLeftBound = (left, scrollLeft, boundary = BOUNDARY) => {
return left <= scrollLeft - boundary;
};
const exceedsBottomBound = (top, elementRect, boundary = BOUNDARY) => {
const bottomBound = window.innerHeight + document.body.scrollTop || 0;
return top + elementRect.height >= bottomBound - boundary;
};
const exceedsTopBound = (top, boundary = BOUNDARY) => {
return top <= (document.body.scrollTop || 0) - boundary;
};
const clampHorizontal = (rect, elementRect, left, boundary = BOUNDARY) => {
const bodyRect = document.body.getBoundingClientRect();
const scrollLeft = document.body.scrollLeft || 0;
let arrowLeft = 0;
let nextLeft = left;
if (exceedsLeftBound(left, scrollLeft, boundary)) {
nextLeft = scrollLeft + boundary;
arrowLeft = (rect.width / 2);
} else if (exceedsRightBound(left, elementRect, scrollLeft, boundary)) {
nextLeft = (scrollLeft + bodyRect.width) - elementRect.width - boundary;
arrowLeft = elementRect.width - (rect.width / 2);
}
// console.log(left);
return { nextLeft, arrowLeft };
};
const clampVertical = (rect, elementRect, top, boundary = BOUNDARY) => {
const bodyRect = {
top: document.body.scrollTop || 0,
bottom: window.innerHeight || 0,
};
let arrowBottom = 0;
let nextTop = top;
if (exceedsTopBound(top, boundary)) {
nextTop = boundary + bodyRect.top;
arrowBottom = (rect.height / 2);
} else if (exceedsBottomBound(top, elementRect, boundary)) {
nextTop = bodyRect.bottom - elementRect.height - (rect.height / 2) - boundary;
arrowBottom = (rect.height / 2);
}
// console.log(top);
return { nextTop, arrowBottom };
};
const computeLeft = (rect, elementRect, scrollLeft) => {
const left = (rect.left || 0) + scrollLeft + (-elementRect.width / 2) + (rect.width / 2);
return left;
};
const computeTop = (rect, elementRect, scrollTop) => {
return rect.top + scrollTop + (rect.height / 2) + (-elementRect.height / 2);
};
class PositionProvider extends React.Component {
constructor(props) {
super(props);
this.state = {
nextStyle: {},
};
this.getStyle = this.getStyle.bind(this);
this.positionElement = this.positionElement.bind(this);
this.getBottom = this.getBottom.bind(this);
this.getTop = this.getTop.bind(this);
this.getLeft = this.getLeft.bind(this);
this.getRight = this.getRight.bind(this);
this.handleFocusChange = this.handleFocusChange.bind(this);
}
positionElement(nextStyle) {
if (!this.el) {
return;
}
this.el.style.top = `${nextStyle.top}px`;
this.el.style.left = `${nextStyle.left}px`;
const arrows = Array.prototype.slice.call(this.el.querySelectorAll('[data-tooltip-arrow]'));
arrows.forEach(node => {
const arrow = node;
if (nextStyle.arrowLeft) {
arrow.style.left = `${nextStyle.arrowLeft}px`;
}
if (nextStyle.arrowBottom) {
arrow.style.bottom = `${nextStyle.arrowBottom}px`;
}
});
}
componentDidMount() {
this.positionElement(this.getStyle());
// Bite me, eslint.
/* eslint-disable react/no-did-mount-set-state */
this.setState({
nextStyle: this.getStyle(),
});
if (this.tooltip) {
this.tooltip.focus();
}
document.addEventListener('focus', this.handleFocusChange, true);
}
handleFocusChange(e) {
const dialog = this.tooltip;
if (!dialog.contains(e.target)) {
e.stopPropagation();
dialog.focus();
}
}
componentDidUpdate() {
this.positionElement(this.getStyle());
}
componentWillUnmount() {
document.removeEventListener('focus', this.handleFocusChange, true);
this.props.target.focus();
}
getArrow() {
const { arrowSize = SIZE, arrowOffset } = this.props;
return arrowSize + (typeof arrowOffset !== 'undefined' ? arrowOffset : DEFAULT_ARROW_MARGIN);
}
getTop(rect, elementRect, scrollTop = 0, scrollLeft = 0) {
const left = computeLeft(rect, elementRect, scrollLeft);
const { nextLeft, arrowLeft } = clampHorizontal(rect, elementRect, left, this.props.boundary);
return {
left: nextLeft,
top: rect.top + scrollTop + (-this.getArrow()) + (-elementRect.height),
arrowLeft,
};
}
getBottom(rect, elementRect, scrollTop = 0, scrollLeft = 0) {
const left = computeLeft(rect, elementRect, scrollLeft);
const { nextLeft, arrowLeft } = clampHorizontal(rect, elementRect, left, this.props.boundary);
return {
left: nextLeft,
top: rect.bottom + scrollTop + (this.getArrow()),
arrowLeft,
};
}
getLeft(rect, elementRect, scrollTop = 0, scrollLeft = 0) {
const top = computeTop(rect, elementRect, scrollTop);
const { nextTop, arrowBottom } = clampVertical(rect, elementRect, top, this.props.boundary);
return {
left: rect.left + scrollLeft + (-elementRect.width) + (-this.getArrow()),
top: nextTop,
arrowBottom,
};
}
getRight(rect, elementRect, scrollTop = 0, scrollLeft = 0) {
const top = computeTop(rect, elementRect, scrollTop);
const { nextTop, arrowBottom } = clampVertical(rect, elementRect, top, this.props.boundary);
return {
left: rect.right + scrollLeft + (this.getArrow()),
top: nextTop,
arrowBottom,
};
}
determineNextPosition(result, position, elementRect, scrollTop, scrollLeft, boundary) {
if (position === 'top') {
if (exceedsTopBound(result.top, scrollTop, boundary)) {
return 'bottom';
}
} else if (position === 'bottom') {
if (exceedsBottomBound(result.top, elementRect, boundary)) {
return 'top';
}
} else if (position === 'left') {
if (exceedsLeftBound(result.left, scrollLeft, boundary)) {
return 'right';
}
} else if (position === 'right') {
if (exceedsRightBound(result.left, elementRect, scrollLeft, boundary)) {
return 'left';
}
}
return null;
}
getStyle() {
const { target, position } = this.props;
const rect = target ? target.getBoundingClientRect() : { left: 0, top: 0 };
const scrollTop = document.body.scrollTop || 0;
const scrollLeft = document.body.scrollLeft || 0;
const positionWithDefault = position || POSITION;
if (!rect) {
return { left: 0, top: 0 };
}
const elementRect = this.el ? this.el.getBoundingClientRect() : { left: 0, top: 0 };
const methods = {
bottom: this.getBottom,
top: this.getTop,
left: this.getLeft,
right: this.getRight,
};
// Try to give the user what they wanted.
let result = methods[positionWithDefault](rect, elementRect, scrollTop, scrollLeft);
const nextPosition = this.determineNextPosition(
result,
positionWithDefault,
elementRect,
scrollTop,
scrollLeft,
this.props.boundary
);
// Otherwise, give them what they need.
if (nextPosition) {
result = methods[nextPosition](rect, elementRect, scrollTop, scrollLeft);
result.nextPosition = nextPosition;
}
return result;
}
render() {
const { children, id, label, options } = this.props;
const nextStyle = this.state.nextStyle;
let nextOptions = options;
const onClick = (e) => {
e.stopPropagation();
e.nativeEvent.stopImmediatePropagation();
};
const style = { position: 'absolute' };
if (nextStyle.nextPosition) {
nextOptions = Object.assign({}, options, { position: nextStyle.nextPosition });
}
return (
<div
id={id}
ref={(node) => { this.el = node; }}
onClick={onClick}
style={style}
>
<ToolTipArrow options={nextOptions} />
{ nextOptions.useForeground ?
<ToolTipArrow options={nextOptions} foreground={true} /> : null }
<div
ref={(node) => { this.tooltip = node; }}
tabIndex="-1"
aria-labelledby={`label_for_${this.props.id}`}
role="tooltip"
className={this.props.classes}
style={this.props.style}
>
{label ? <h2 id={`label_for_${id}`} style={CLASSES.visuallyHidden}>{label}</h2> : null}
{children}
</div>
</div>
);
}
}
PositionProvider.propTypes = {
children: React.PropTypes.node,
target: React.PropTypes.object,
options: React.PropTypes.object,
position: React.PropTypes.string,
label: React.PropTypes.string,
id: React.PropTypes.string,
arrowSize: React.PropTypes.number,
arrowOffset: React.PropTypes.number,
boundary: React.PropTypes.number,
classes: React.PropTypes.string,
style: React.PropTypes.object,
};
export default PositionProvider;