UNPKG

boldr-ui

Version:

UI components for Boldr

402 lines (356 loc) 10.9 kB
/* eslint-disable react/no-find-dom-node */ import React, { cloneElement } from 'react'; import PropTypes from 'prop-types'; import ReactDOM from 'react-dom'; import BoldrComponent from '../utils/BoldrComponent'; import TooltipContent from './TooltipContent'; import position from './TooltipPosition'; const renderSubtreeIntoContainer = ReactDOM.unstable_renderSubtreeIntoContainer; class Tooltip extends BoldrComponent { componentElements() { const elements = super.componentElements(); return this._mountNode ? elements.concat(this._mountNode) : elements; } onClickOutside(e) { if (this.props.shouldCloseOnClickOutside) { this.hide(); } else if (this.props.onClickOutside) { this.props.onClickOutside && this.props.onClickOutside(e); } } static propTypes = { textAlign: PropTypes.string, children: PropTypes.node, content: PropTypes.node.isRequired, placement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']), alignment: PropTypes.oneOf(['top', 'right', 'bottom', 'left', 'center']), theme: PropTypes.oneOf(['light', 'dark', 'error']), showDelay: PropTypes.number, hideDelay: PropTypes.number, showTrigger: PropTypes.oneOf(['custom', 'mouseenter', 'mouseleave', 'click', 'focus', 'blur']), hideTrigger: PropTypes.oneOf(['custom', 'mouseenter', 'mouseleave', 'click', 'focus', 'blur']), active: PropTypes.bool, bounce: PropTypes.bool, disabled: PropTypes.bool, maxWidth: PropTypes.string, onClickOutside: PropTypes.func, /** * Callback to be called when the tooltip has been shown */ onShow: PropTypes.func, zIndex: PropTypes.number, /** * By default tooltip is appended to a body, to avoid CSS collisions. * But if you want your tooltip to scroll with a content, append tooltip to a parent. * Just make sure the CSS are not leaked. */ appendToParent: PropTypes.bool, /** * Allows to shift the tooltip position by x and y pixels. * Both positive and negative values are accepted. */ moveBy: PropTypes.shape({ x: PropTypes.number, y: PropTypes.number, }), /** * Allows to position the arrow relative to tooltip. * Positive value calculates position from left/top. * Negative one calculates position from right/bottom. */ moveArrowTo: PropTypes.number, size: PropTypes.oneOf(['normal', 'large']), shouldCloseOnClickOutside: PropTypes.bool, }; static defaultProps = { placement: 'left', alignment: 'center', showTrigger: 'mouseenter', hideTrigger: 'mouseleave', showDelay: 200, hideDelay: 500, zIndex: 2000, maxWidth: '1200px', onClickOutside: null, onShow: null, active: false, theme: 'light', disabled: false, children: null, size: 'normal', shouldCloseOnClickOutside: false, textAlign: 'center', }; _childNode = null; _mountNode = null; _showTimeout = null; _hideTimeout = null; _unmounted = false; state = { visible: false, hidden: true, }; componentDidUpdate() { if (this._mountNode && this.state.visible) { const arrowPlacement = { top: 'bottom', left: 'right', right: 'left', bottom: 'top', }; const tooltip = ( <TooltipContent onMouseEnter={() => this._onTooltipContentEnter()} onMouseLeave={() => this._onTooltipContentLeave()} ref={ref => (this.tooltipContent = ref)} theme={this.props.theme} bounce={this.props.bounce} arrowPlacement={arrowPlacement[this.props.placement]} style={{ zIndex: this.props.zIndex }} arrowStyle={this.state.arrowStyle} maxWidth={this.props.maxWidth} size={this.props.size} textAlign={this.props.textAlign} > {this.props.content} </TooltipContent> ); renderSubtreeIntoContainer(this, tooltip, this._mountNode); } } componentWillUnmount() { super.componentWillUnmount && super.componentWillUnmount(); this._unmounted = true; this._getContainer() && this.hide(); } componentWillMount() { super.componentWillMount && super.componentWillMount(); if (this.props.active) { this.show(); } } componentWillReceiveProps(nextProps) { super.componentWillReceiveProps && super.componentWillReceiveProps(nextProps); if (nextProps.active !== this.props.active) { if (this.state.visible && this.props.hideTrigger === 'custom') { if (!nextProps.active) { this.hide(); } } if (!this.state.visible && this.props.showTrigger === 'custom') { if (nextProps.active) { this.show(); } } } } render() { const child = this.props.children; if (child) { return cloneElement(child, { ref: ref => (this._childNode = ReactDOM.findDOMNode(ref)), onClick: this._chainCallbacks(child.props ? child.props.onClick : null, this._onClick), onMouseEnter: this._chainCallbacks( child.props ? child.props.onMouseEnter : null, this._onMouseEnter, ), onMouseLeave: this._chainCallbacks( child.props ? child.props.onMouseLeave : null, this._onMouseLeave, ), onFocus: this._chainCallbacks(child.props ? child.props.onFocus : null, this._onFocus), onBlur: this._chainCallbacks(child.props ? child.props.onBlur : null, this._onBlur), }); } else { return <div />; } } _chainCallbacks = (first, second) => { return args => { if (first) { first.apply(this, args); } if (second) { second.apply(this, args); } }; }; _getContainer() { if (typeof document === 'undefined') { return null; } return this.props.appendToParent ? this._childNode.parentElement : document ? document.body : null; } show() { if (this.props.disabled) { return; } if (this._unmounted) { return; } this.setState({ hidden: false }); if (this._hideTimeout) { clearTimeout(this._hideTimeout); this._hideTimeout = null; } if (this._showTimeout) { return; } if (!this.state.visible) { this._showTimeout = setTimeout(() => { if (this.props.onShow) { this.props.onShow(); } this.setState({ visible: true }, () => { if (!this._mountNode) { this._mountNode = document.createElement('div'); this._getContainer() && this._getContainer().appendChild(this._mountNode); } this._showTimeout = null; let fw = 0; let sw = 0; do { this.componentDidUpdate(); const tooltipNode = ReactDOM.findDOMNode(this.tooltipContent); fw = this._getRect(tooltipNode).width; this._updatePosition(this.tooltipContent); sw = this._getRect(tooltipNode).width; } while (!this.props.appendToParent && fw !== sw); }); }, this.props.showDelay); } } hide() { this.setState({ hidden: true }); if (this._showTimeout) { clearTimeout(this._showTimeout); this._showTimeout = null; } if (this._hideTimeout) { return; } if (this.state.visible) { this._hideTimeout = setTimeout(() => { if (this._mountNode) { ReactDOM.unmountComponentAtNode(this._mountNode); this._getContainer() && this._getContainer().removeChild(this._mountNode); this._mountNode = null; } this._hideTimeout = null; if (!this._unmounted) { this.setState({ visible: false }); } }, this._unmounted ? 0 : this.props.hideDelay); } } _hideOrShow(event) { if (this.props.hideTrigger === event && !this.state.hidden) { this.hide(); } else if (this.props.showTrigger === event) { this.show(); } } _onBlur() { this._hideOrShow('blur'); } _onFocus() { this._hideOrShow('focus'); } _onClick() { this._hideOrShow('click'); } _onMouseEnter() { this._hideOrShow('mouseenter'); } _onMouseLeave() { this._hideOrShow('mouseleave'); } _calculatePosition(ref, tooltipNode) { if (!ref || !tooltipNode) { return { top: -1, left: -1, }; } return this._adjustPosition( position( this._getRect(this._childNode), this._getRect(tooltipNode), { placement: this.props.placement, alignment: this.props.alignment, margin: 10, }, this.props.relative, ), ); } _updatePosition(ref) { if (ref && this._childNode) { const tooltipNode = ReactDOM.findDOMNode(ref); const style = this._calculatePosition(ref, tooltipNode); if (this.props.relative) { tooltipNode.style.top = `${style.top}px`; tooltipNode.style.left = `${style.left}px`; } else { tooltipNode.style.top = `${style.top}px`; tooltipNode.style.left = `${Math.max(style.left, 0)}px`; } const arrowStyles = this._adjustArrowPosition(this.props.placement, this.props.moveArrowTo); if (Object.keys(arrowStyles).length) { const arrow = tooltipNode.querySelector(`.boldrui-tooltip__arrow`); arrow && Object.keys(arrowStyles).forEach(key => { arrow.style[key] = arrowStyles[key]; }); } } } _adjustArrowPosition(placement, moveTo) { if (moveTo) { const isPositive = moveTo > 0; const pixels = isPositive ? moveTo : -moveTo; if (['top', 'bottom'].includes(placement)) { return isPositive ? { left: `${pixels}px` } : { left: 'auto', right: `${pixels}px` }; } return isPositive ? { top: `${pixels}px` } : { top: 'auto', bottom: `${pixels}px` }; } return {}; } _getRect(el) { if (this.props.appendToParent) { // TODO: Once thoroughly tested, we could use the same approach in both cases. return { left: el.offsetLeft, top: el.offsetTop, width: el.offsetWidth, height: el.offsetHeight, }; } return el.getBoundingClientRect(); } _adjustPosition(originalPosition) { let { x = 0, y = 0 } = this.props.moveBy || {}; // TODO: Once thoroughly tested, and converted to using offsetX props, we could remove this one. if (!this.props.appendToParent) { x += window.scrollX || 0; y += window.scrollY || 0; } return { left: originalPosition.left + x, top: originalPosition.top + y, }; } _onTooltipContentEnter() { this.show(); } _onTooltipContentLeave() { this._onMouseLeave(); } isShown() { return this.state.visible; } } export default Tooltip;