Working tooltips!
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Map } from 'immutable'
|
||||
|
||||
const TOOLTIP_ROOT = document.getElementById('tooltip-root')
|
||||
|
||||
interface Props {
|
||||
position?: TooltipPosition
|
||||
}
|
||||
|
||||
export class Controller extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
position: 'top-right'
|
||||
}
|
||||
|
||||
tooltip = React.createRef<HTMLSpanElement>()
|
||||
trigger = React.createRef<React.ReactInstance>()
|
||||
|
||||
state = {
|
||||
isVisible: false,
|
||||
tooltipDimensions: Map({
|
||||
width: 0,
|
||||
height: 0
|
||||
}),
|
||||
style: Map({
|
||||
position: 'absolute' as 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
}),
|
||||
triggerRect: Map({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
})
|
||||
}
|
||||
|
||||
handleMouseEnter = () => {
|
||||
this.setState({
|
||||
isVisible: true,
|
||||
style: this.state.style.merge(this.getPosition())
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
handleMouseLeave = () => {
|
||||
this.setState({ isVisible: false })
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (this.state.isVisible) {
|
||||
const { width, height } = (ReactDOM.findDOMNode(this.tooltip.current) as HTMLElement).getBoundingClientRect()
|
||||
const { style, tooltipDimensions } = this.state
|
||||
|
||||
this.setState({
|
||||
tooltipDimensions: tooltipDimensions.merge({ width, height }),
|
||||
style: style.merge(this.getPosition())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
getPosition = (): { top: number, left: number } => {
|
||||
const { tooltipDimensions, triggerRect } = this.state
|
||||
return calculatePosition(this.props.position, triggerRect, tooltipDimensions)
|
||||
}
|
||||
|
||||
updateTriggerRect = (triggerRect: { left: number, top: number, width: number, height: number }) => {
|
||||
this.setState({
|
||||
triggerRect: this.state.triggerRect.merge(triggerRect)
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children } = this.props
|
||||
const { isVisible, style } = this.state
|
||||
|
||||
return React.Children.map(children, (child: React.ReactElement) => {
|
||||
const name = (child.type as any).name
|
||||
if (name === 'Trigger') {
|
||||
return React.cloneElement(child, {
|
||||
ref: this.trigger,
|
||||
resize: this.updateTriggerRect,
|
||||
onMouseEnter: this.handleMouseEnter,
|
||||
onMouseLeave: this.handleMouseLeave,
|
||||
})
|
||||
} else {
|
||||
// Tooltip
|
||||
return isVisible && createPortal(
|
||||
<span style={style.toJS()} ref={this.tooltip}>
|
||||
{React.cloneElement(child)}
|
||||
</span>,
|
||||
TOOLTIP_ROOT
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const TOOLTIP_BOUNDING_PADDING = 10
|
||||
|
||||
const calculatePosition = (pos: TooltipPosition, trigger: Map<string, number>, tooltip: Map<string, number>) => {
|
||||
const { innerWidth: windowWidth } = window
|
||||
let top = 0
|
||||
let left = 0
|
||||
|
||||
const triggerTop = trigger.get('top') + window.pageYOffset
|
||||
|
||||
// Top
|
||||
switch (pos) {
|
||||
case 'top-left':
|
||||
case 'top-right': {
|
||||
top = triggerTop - (tooltip.get('height') + 5)
|
||||
break
|
||||
}
|
||||
|
||||
case 'bottom-left':
|
||||
case 'bottom-right': {
|
||||
top = triggerTop + trigger.get('height') + 5
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Left
|
||||
switch (pos) {
|
||||
case 'left':
|
||||
case 'bottom-left':
|
||||
case 'top-left': {
|
||||
left = (trigger.get('left')) - tooltip.get('width')
|
||||
break
|
||||
}
|
||||
|
||||
case 'right':
|
||||
case 'bottom-right':
|
||||
case 'top-right': {
|
||||
left = (trigger.get('left')) + trigger.get('width')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const overflowsRight = left + tooltip.get('width') + TOOLTIP_BOUNDING_PADDING > windowWidth
|
||||
const overflowsTop = top - (window.scrollY + TOOLTIP_BOUNDING_PADDING) < 0
|
||||
|
||||
// Validate
|
||||
switch (pos) {
|
||||
case 'top-right': {
|
||||
if (overflowsRight && !overflowsTop) return calculatePosition('top-left', trigger, tooltip)
|
||||
if (!overflowsRight && overflowsTop) return calculatePosition('bottom-right', trigger, tooltip)
|
||||
if (overflowsRight && overflowsTop) return calculatePosition('bottom-left', trigger, tooltip)
|
||||
break
|
||||
}
|
||||
|
||||
case 'top-left': {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return { top, left }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
@mixin tooltip-base {
|
||||
font-size: 12px;
|
||||
font-family: Verdana, Geneva, Tahoma, sans-serif;
|
||||
line-height: 1.4;
|
||||
|
||||
&__inner {
|
||||
display: inline-block;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&__top {
|
||||
display: flex;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
padding: 3px;
|
||||
background: url('../../images/tooltip-background.png');
|
||||
background-position: top right;
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
min-height: 2px;
|
||||
padding: 8px 3px 2px 9px;
|
||||
background: url('../../images/tooltip-background.png');
|
||||
background-position: top left;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
flex: 1;
|
||||
padding: 3px;
|
||||
background: url('../../images/tooltip-background.png');
|
||||
background-position: bottom left;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
padding: 3px;
|
||||
background: url('../../images/tooltip-background.png');
|
||||
background-position: bottom right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
@include tooltip-base;
|
||||
display: flex;
|
||||
|
||||
&--inline {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&--fixed {
|
||||
.tooltip__inner {
|
||||
width: 100%;
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.tooltip__body {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
margin-right: .25em;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&__body {
|
||||
p:last-child {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import './Tooltip.scss'
|
||||
import React from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { Icon } from '../Icon'
|
||||
|
||||
export interface Props {
|
||||
title?: string
|
||||
/** Override width of tooltip. Needs `fixed` to be true to have effect. */
|
||||
width?: string
|
||||
/** Fixed width */
|
||||
fixed?: boolean
|
||||
/** Display tooltip inline */
|
||||
inline?: boolean
|
||||
/** Icon to show next to tooltip */
|
||||
icon?: string | false
|
||||
anchor?: HTMLElement
|
||||
style?: any
|
||||
}
|
||||
|
||||
export class Tooltip extends React.PureComponent<Props> {
|
||||
static defaultProps = {
|
||||
style: {}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { title, icon, children } = this.props
|
||||
|
||||
const cn = classNames('tooltip', {
|
||||
'tooltip--fixed': this.props.fixed,
|
||||
'tooltip--inline': this.props.inline,
|
||||
})
|
||||
|
||||
const innerStyle = {
|
||||
width: this.props.width
|
||||
}
|
||||
|
||||
const style = {
|
||||
opacity: 1,
|
||||
...this.props.style,
|
||||
}
|
||||
|
||||
return <div className={cn} style={style}>
|
||||
{icon &&
|
||||
<Icon className="tooltip__icon" name={icon} />
|
||||
}
|
||||
<div className="tooltip__inner" style={innerStyle}>
|
||||
<div className="tooltip__top">
|
||||
<div className="tooltip__body">
|
||||
{title && <div className="tooltip__title tight">{title}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tooltip__footer" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { debounce } from '../../lib/helpers'
|
||||
|
||||
interface Props {
|
||||
resize?: (rect: { left: number, top: number, width: number, height: number }) => void
|
||||
children: React.ReactElement
|
||||
}
|
||||
|
||||
export class Trigger extends React.PureComponent<Props> {
|
||||
trigger = React.createRef<HTMLElement>()
|
||||
|
||||
get boundingRect() {
|
||||
if (!this.trigger.current) {
|
||||
throw new Error('Trigger does not have reference to itself')
|
||||
}
|
||||
const { width, height, top, left } = (ReactDOM.findDOMNode(this.trigger.current) as HTMLElement).getBoundingClientRect()
|
||||
return { width, height, top, left }
|
||||
}
|
||||
|
||||
resize = debounce(() => this.props.resize(this.boundingRect), 250)
|
||||
|
||||
componentDidMount() {
|
||||
this.resize()
|
||||
window.addEventListener('scroll', this.resize)
|
||||
window.addEventListener('resize', this.resize)
|
||||
}
|
||||
|
||||
componentWillUnmount(){
|
||||
window.removeEventListener('scroll', this.resize)
|
||||
window.removeEventListener('resize', this.resize)
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.resize()
|
||||
}
|
||||
|
||||
render() {
|
||||
const { resize, children, ...props } = this.props
|
||||
return React.cloneElement(children, {
|
||||
...props,
|
||||
ref: this.trigger
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './Tooltip'
|
||||
export { Controller } from './Controller'
|
||||
export { Trigger } from './Trigger'
|
||||
Reference in New Issue
Block a user