Working tooltips!

This commit is contained in:
Melvin Valster
2019-07-26 18:20:08 +02:00
parent 90fdc8d0e9
commit 4f72889e68
18 changed files with 476 additions and 127 deletions
+161
View File
@@ -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 }
}
+85
View File
@@ -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;
}
}
}
+58
View File
@@ -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>
}
}
+45
View File
@@ -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
})
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './Tooltip'
export { Controller } from './Controller'
export { Trigger } from './Trigger'