react-components
Version:
React components used by Khan Academy
386 lines (346 loc) • 12.5 kB
JSX
/**
* A generic tooltip library for React.js
*
* This should eventually end up in react-components
*
* Interface: ({a, b} means one of a or b)
* const Tooltip = require("./tooltip.jsx");
* <Tooltip
* className="class-for-tooltip-contents"
* horizontalPosition="left" // one of "left", "right"
* horizontalAlign="left" // one of "left", "right"
* verticalPosition="bottom" // one of "top", "bottom"
* arrowSize={10} // arrow size in pixels
* borderColor="#ccc" // color of the border for the tooltip
* show={true} // whether the tooltip should currently be visible
* targetContainerStyle={targetContainerStyle}
* >
* <TargetElementOfTheTooltip />
* <TooltipContents1 />
* <TooltipContents2 />
* </Tooltip>
*
* To show/hide the tooltip, the parent component should call the
* .show() and .hide() methods of the tooltip when appropriate.
* (These are usually set up as handlers of events on the target element.)
*
* Notes:
* className should not specify a border; that is handled by borderColor
* so that the arrow and tooltip match
*/
// __,,--``\\
// _,,-''`` \\ ,
// '----------_.------'-.___|\__
// _.--''`` `)__ )__ @\__
// ( .. ''---/___,,E/__,E'------`
// `-''`''
// Here be dragons.
// TODO(joel/aria) fix z-index issues https://s3.amazonaws.com/uploads.hipchat.com/6574/29028/yOApjwmgiMhEZYJ/Screen%20Shot%202014-05-30%20at%203.34.18%20PM.png
// z-index: 3 on perseus-formats-tooltip seemed to work
const React = require("react");
const ReactDOM = require("react-dom");
const zIndex = 10;
const Triangle = React.createClass({
propTypes: {
color: React.PropTypes.string.isRequired,
left: React.PropTypes.number.isRequired,
"top": React.PropTypes.number.isRequired,
width: React.PropTypes.number.isRequired,
height: React.PropTypes.number.isRequired,
horizontalDirection: React.PropTypes.oneOf(
["left", "right"]
).isRequired,
verticalDirection: React.PropTypes.oneOf(
["top", "bottom"]
).isRequired,
},
render: function() {
let borderLeft;
let borderRight;
let borderTop;
let borderBottom;
const hBorder = `${this.props.width}px solid transparent`;
if (this.props.horizontalDirection === "right") {
borderLeft = hBorder;
} else {
borderRight = hBorder;
}
const vBorder = `${this.props.height}px solid ${this.props.color}`;
if (this.props.verticalDirection === "top") {
borderTop = vBorder;
} else {
borderBottom = vBorder;
}
return <div
style={{
display: "block",
height: 0,
width: 0,
position: "absolute",
left: this.props.left,
"top": this.props["top"],
borderLeft: borderLeft,
borderRight: borderRight,
borderTop: borderTop,
borderBottom: borderBottom,
}}
/>;
},
});
const TooltipArrow = React.createClass({
propTypes: {
position: React.PropTypes.string,
visibility: React.PropTypes.string,
left: React.PropTypes.number,
"top": React.PropTypes.number,
color: React.PropTypes.string.isRequired, // a css color
border: React.PropTypes.string.isRequired, // a css color
width: React.PropTypes.number.isRequired,
height: React.PropTypes.number.isRequired,
horizontalDirection: React.PropTypes.oneOf(
["left", "right"]
).isRequired,
verticalDirection: React.PropTypes.oneOf(
["top", "bottom"]
).isRequired,
},
getDefaultProps: function() {
return {
position: "relative",
visibility: "visible",
left: 0,
"top": 0,
};
},
// TODO(aria): Think about adding a box-shadow to the triangle here
// See http://css-tricks.com/triangle-with-shadow/
render: function() {
//const isRight = (this.props.horizontalDirection === "right");
const isTop = (this.props.verticalDirection === "top");
const frontTopOffset = isTop ? 0 : 1;
const borderTopOffset = isTop ? 0 : -1;
return <div style={{
display: "block",
position: this.props.position,
visibility: this.props.visibility,
left: this.props.left,
"top": this.props["top"],
width: this.props.width + 2,
height: this.props.height + 1,
marginTop: -1,
marginBottom: -2,
zIndex: zIndex,
}}
>
{/* The background triangle used to create the effect of a
border around the foreground triangle*/}
<Triangle
horizontalDirection={this.props.horizontalDirection}
verticalDirection={this.props.verticalDirection}
color={this.props.border}
left={0}
top={borderTopOffset}
width={this.props.width + 2} // one extra for the diagonal
height={this.props.height + 2}
/>
{/* The foreground triangle covers all but the left/right edges
of the background triangle */}
<Triangle
horizontalDirection={this.props.horizontalDirection}
verticalDirection={this.props.verticalDirection}
color={this.props.color}
left={1}
top={frontTopOffset}
width={this.props.width}
height={this.props.height}
/>
</div>;
},
});
const VERTICAL_CORNERS = {
"top": {
"top": "-100%",
},
bottom: {
"top": 0,
},
};
const HORIZONTAL_CORNERS = {
left: {
targetLeft: 0,
},
right: {
targetLeft: "100%",
},
};
const HORIZONTAL_ALIGNMNENTS = {
left: {
tooltipLeft: 0,
arrowLeft: (arrowSize) => 0,
},
right: {
tooltipLeft: "-100%",
arrowLeft: (arrowSize) => -arrowSize - 2,
},
};
const Tooltip = React.createClass({
propTypes: {
show: React.PropTypes.bool.isRequired,
className: React.PropTypes.string,
arrowSize: React.PropTypes.number,
borderColor: React.PropTypes.string,
verticalPosition: React.PropTypes.oneOf(
Object.keys(VERTICAL_CORNERS)
),
horizontalPosition: React.PropTypes.oneOf(
Object.keys(HORIZONTAL_CORNERS)
),
horizontalAlign: React.PropTypes.oneOf(
Object.keys(HORIZONTAL_ALIGNMNENTS)
),
children: React.PropTypes.arrayOf(
React.PropTypes.element
).isRequired,
targetContainerStyle: React.PropTypes.any, // style object
},
getDefaultProps: function() {
return {
className: "",
arrowSize: 10,
borderColor: "#ccc",
verticalPosition: "bottom",
horizontalPosition: "left",
horizontalAlign: "left",
targetContainerStyle: {},
};
},
getInitialState: function() {
return {
height: null, // used for offsetting "top" positioned tooltips
};
},
componentDidMount: function() {
this._updateHeight();
},
componentWillReceiveProps: function() {
// If the contents have changed, reset our measure of the height
this.setState({height: null});
},
componentDidUpdate: function() {
this._updateHeight();
},
_renderToolTipDiv: function(isTooltipAbove) {
const settings = Object.assign({},
HORIZONTAL_CORNERS[this.props.horizontalPosition],
HORIZONTAL_ALIGNMNENTS[this.props.horizontalAlign],
VERTICAL_CORNERS[this.props.verticalPosition]
);
let arrowAbove;
let arrowBelow;
if (isTooltipAbove) {
// We put an absolutely positioned arrow in the correct place
arrowAbove = <TooltipArrow
verticalDirection="top"
horizontalDirection={this.props.horizontalAlign}
position="absolute"
color="white"
border={this.props.borderColor}
left={settings.arrowLeft(this.props.arrowSize)}
top={-this.props.arrowSize + 2}
width={this.props.arrowSize}
height={this.props.arrowSize}
zIndex={zIndex}
/>;
// And we use a visibility: hidden arrow below to shift up the
// content by the correct amount
arrowBelow = <TooltipArrow
verticalDirection="top"
horizontalDirection={this.props.horizontalAlign}
visibility="hidden"
color="white"
border={this.props.borderColor}
left={settings.arrowLeft(this.props.arrowSize)}
top={-1}
width={this.props.arrowSize}
height={this.props.arrowSize}
zIndex={zIndex}
/>;
} else {
arrowAbove = <TooltipArrow
verticalDirection="bottom"
horizontalDirection={this.props.horizontalAlign}
color="white"
border={this.props.borderColor}
left={settings.arrowLeft(this.props.arrowSize)}
top={-1}
width={this.props.arrowSize}
height={this.props.arrowSize}
zIndex={zIndex}
/>;
arrowBelow = null;
}
/* A positioned div below the input to be the parent for our
tooltip */
return <div style={{
position: "relative",
height: 0,
display: this.props.show ? "block" : "none",
}}
>
<div ref="tooltipContainer" className="tooltipContainer" style={{
position: "absolute",
// height must start out undefined, not null, so that
// we can measure the actual height with jquery.
// This is used to position the tooltip with top: -100%
// when in verticalPosition: "top" mode
height: this.state.height || undefined,
left: settings.targetLeft,
}}
>
{arrowAbove}
{/* The contents of the tooltip */}
<div className={this.props.className}
ref="tooltipContent"
style={{
position: "relative",
top: settings["top"],
left: settings.tooltipLeft,
border: "1px solid " + this.props.borderColor,
WebkitBoxShadow: "0 1px 3px " +
this.props.borderColor,
MozBoxShadow: "0 1px 3px " +
this.props.borderColor,
boxShadow: "0 1px 3px " +
this.props.borderColor,
zIndex: zIndex - 1,
}}
>
{this.props.children.slice(1)}
</div>
{arrowBelow}
</div>
</div>;
},
_updateHeight: function() {
const height =
ReactDOM.findDOMNode(this.refs.tooltipContainer).offsetHeight;
if (height !== this.state.height) {
this.setState({height});
}
},
render: function() {
const isTooltipAbove = this.props.verticalPosition === "top";
/* We wrap the entire output in a span so that it displays inline */
return <span>
{isTooltipAbove && this._renderToolTipDiv(isTooltipAbove)}
{/* We wrap our input in a div so that we can put the tooltip in a
div above/below it */}
<div style={this.props.targetContainerStyle}>
{this.props.children[0]}
</div>
{!isTooltipAbove && this._renderToolTipDiv()}
</span>;
},
});
// Sorry. // Apology-Oriented-Programming
module.exports = Tooltip;