UNPKG

wix-style-react

Version:
648 lines (566 loc) • 17.7 kB
import React, { cloneElement } from 'react'; import PropTypes from 'prop-types'; import WixComponent from '../BaseComponents/WixComponent'; import ReactDOM from 'react-dom'; import TooltipContent from './TooltipContent'; import position from './TooltipPosition'; import styles from './TooltipContent.scss'; import { TooltipContainerStrategy } from './TooltipContainerStrategy'; import throttle from 'lodash/throttle'; const renderSubtreeIntoContainer = ReactDOM.unstable_renderSubtreeIntoContainer; // TestId is a uniq Tooltip id, used to find the content element in tests. let testId = 0; function nextTestId() { testId++; return testId; } //maintain a 60fps rendering const createAThrottledOptimizedFunction = cb => () => window.requestAnimationFrame(throttle(cb, 16)); const popoverConfig = { contentClassName: styles.popoverTooltipContent, theme: 'light', showTrigger: 'click', hideTrigger: 'click', }; /** A Tooltip component */ class Tooltip extends WixComponent { static displayName = 'Tooltip'; static propTypes = { dataHook: PropTypes.string, /** alignment of the tooltip's text */ 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, /** Apply popover styles and even triggers */ popover: PropTypes.bool, /** The tooltip max width */ maxWidth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), /** The tooltip min width */ minWidth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), /** Callback when cliking outside */ onClickOutside: PropTypes.func, /** override the theme text color of the tooltip */ color: PropTypes.string, /** override the theme text line height of the tooltip */ lineHeight: PropTypes.string, /** Callback to be called when the tooltip has been shown */ onShow: PropTypes.func, /** Callback to be called when the tooltip has been hidden */ onHide: PropTypes.func, /** z index of the tooltip */ zIndex: PropTypes.number, /** * In some cases when you need a tooltip scroll with your element, you can append the tooltip to the direct parent, just * don't forget to apply `relative`, `absolute` positioning. And be aware that some of your styles may leak into * tooltip content. */ appendToParent: PropTypes.bool, /** * In cases where you need to append the tooltip to some ancestor which is not the direct parent, you can pass a * predicate function of the form `(element: DOMElement) => Boolean`, and the tooltip will be attached to the * closest ancestor for which the predicate returns `true` */ appendByPredicate: PropTypes.func, /** Element to attach the tooltip to */ appendTo: PropTypes.any, /** * 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, relative: PropTypes.bool, /** Allows changing the padding of the content */ padding: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), /** Allows updating the tooltip position **/ shouldUpdatePosition: PropTypes.bool, /** Show Tooltip Immediately - with no delay and no animation */ showImmediately: PropTypes.bool, /** Show an arrow shape */ showArrow: PropTypes.bool, }; static defaultProps = { placement: 'top', alignment: 'center', showTrigger: 'mouseenter', hideTrigger: 'mouseleave', showDelay: 200, hideDelay: 0, zIndex: 2000, maxWidth: '204px', onClickOutside: null, onShow: null, onHide: null, active: false, theme: 'light', disabled: false, children: null, size: 'normal', shouldCloseOnClickOutside: false, textAlign: 'left', relative: false, shouldUpdatePosition: false, showImmediately: false, showArrow: true, }; _childNode = null; _mountNode = null; _showTimeout = null; _showInterval = null; _hideTimeout = null; _unmounted = false; _containerScrollHandler = null; constructor(props) { super(props); this.state = { visible: false, hidden: true, }; this._tooltipContainerStrategy = new TooltipContainerStrategy( props.appendTo, props.appendToParent, props.appendByPredicate, ); this.testId = nextTestId(); this.contentHook = this._createContentHook(); } _createContentHook = () => `tooltip-content-${this.props.dataHook || ''}-${this.testId}`; _setContentDataHook() { if (this._childNode) { this._childNode.setAttribute('data-content-hook', this.contentHook); } } componentElements() { const elements = super.componentElements(); return this._mountNode ? elements.concat(this._mountNode) : elements; } onClickOutside(e) { if (this.props.shouldCloseOnClickOutside) { this.hide(); } this.props.onClickOutside && this.props.onClickOutside(e); } componentDidUpdate(prevProps) { super.componentDidUpdate(prevProps); if (prevProps.dataHook !== this.props.dataHook) { this.contentHook = this._createContentHook(); this._setContentDataHook(); } this.renderTooltipIntoContainer(); } componentDidMount() { super.componentDidMount && super.componentDidMount(); this._setContentDataHook(); } componentWillUnmount() { super.componentWillUnmount && super.componentWillUnmount(); this._unmounted = true; this._removeNode(); this._getContainer() && this.hide(); if (this._showInterval) { clearInterval(this._showInterval); } } componentWillMount() { super.componentWillMount && super.componentWillMount(); if (this.props.active) { this.show(); } } componentWillReceiveProps(nextProps) { super.componentWillReceiveProps && super.componentWillReceiveProps(nextProps); if ( nextProps.active !== this.props.active || nextProps.disabled !== this.props.disabled ) { if (this.state.visible && this.getTriggers().hideTrigger === 'custom') { if (!nextProps.active || nextProps.disabled) { this.hide(nextProps); } } if (!this.state.visible && this.getTriggers().showTrigger === 'custom') { if (nextProps.active && !nextProps.disabled) { this.show(nextProps); } } } } getTriggers() { return { hideTrigger: this.props.popover ? 'click' : this.props.hideTrigger, showTrigger: this.props.popover ? 'click' : this.props.showTrigger, }; } renderTooltipIntoContainer = () => { if (this._mountNode && this.state.visible) { const contentClassName = this.props.popover ? popoverConfig.contentClassName : ''; const theme = this.props.popover ? popoverConfig.theme : this.props.theme; const arrowPlacement = { top: 'bottom', left: 'right', right: 'left', bottom: 'top', }; const _position = this.props.relative ? 'relative' : 'absolute'; const tooltip = ( <TooltipContent dataHook={this.contentHook} contentClassName={contentClassName} onMouseEnter={() => this._onTooltipContentEnter()} onMouseLeave={() => this._onTooltipContentLeave()} ref={ref => { if (this.props.relative) { this.tooltipContent = ref && ref.tooltip; } else { this.tooltipContent = ref; } }} showImmediately={this.props.showImmediately} theme={theme} bounce={this.props.bounce} arrowPlacement={arrowPlacement[this.props.placement]} style={{ zIndex: this.props.zIndex, _position }} arrowStyle={this.state.arrowStyle} maxWidth={this.props.maxWidth} padding={this.props.padding} minWidth={this.props.minWidth} size={this.props.size} textAlign={this.props.textAlign} lineHeight={this.props.lineHeight} color={this.props.color} showArrow={this.props.showArrow} > {this.props.content} </TooltipContent> ); renderSubtreeIntoContainer(this, tooltip, this._mountNode); if (this.props.shouldUpdatePosition) { setTimeout(() => { this._updatePosition(this.tooltipContent); }); } } }; render() { const child = Array.isArray(this.props.children) ? this.props.children[0] : 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() { return this._tooltipContainerStrategy.getContainer(this._childNode); } show = (props = this.props) => { if (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) { if (this.props.showImmediately) { this._doShow(props); } else { this._showTimeout = setTimeout( () => this._doShow(props), props.showDelay, ); } } }; _doShow(props = this.props) { if (typeof document === 'undefined') { return; } if (props.onShow) { props.onShow(); } this.setState({ visible: true }, () => { if (!this._mountNode) { this._mountNode = document.createElement('div'); const container = this._getContainer(); if (container) { container.appendChild(this._mountNode); this._containerScrollHandler = createAThrottledOptimizedFunction(() => this._updatePosition(this.tooltipContent), ); container.addEventListener('scroll', this._containerScrollHandler); } } this._showTimeout = null; this.renderTooltipIntoContainer(); // To prevent any possible jumping of tooltip, we need to try to update tooltip position in sync way const tooltipNode = ReactDOM.findDOMNode(this.tooltipContent); if (tooltipNode) { this._updatePosition(this.tooltipContent); } let fw = 0; let sw = 0; // we need to set tooltip position after render of tooltip into container, on next event loop setTimeout(() => { let iterations = 0; let pixelChange = 0; do { const _tooltipNode = ReactDOM.findDOMNode(this.tooltipContent); if (_tooltipNode) { fw = this._getRect(_tooltipNode).width; this._updatePosition(this.tooltipContent); sw = this._getRect(_tooltipNode).width; } ++iterations; pixelChange = Math.abs(fw - sw); } while (!props.appendToParent && pixelChange > 0.1 && iterations < 10); }); }); } hide = (props = this.props) => { this.setState({ hidden: true }); if (this._showTimeout) { clearTimeout(this._showTimeout); this._showTimeout = null; } if (this._hideTimeout) { return; } if (this.state.visible) { const hideLazy = () => { props.onHide && props.onHide(); this._hideTimeout = null; if (!this._unmounted) { this._removeNode(); this.setState({ visible: false }); } }; if (this._unmounted) { return hideLazy(); } this._hideTimeout = setTimeout(hideLazy, props.hideDelay); } }; _removeNode() { if (this._mountNode) { ReactDOM.unmountComponentAtNode(this._mountNode); const container = this._getContainer(); if (container) { container.removeChild(this._mountNode); container.removeEventListener('scroll', this._containerScrollHandler); } this._mountNode = null; } } _hideOrShow(event) { if (this.getTriggers().hideTrigger === event && !this.state.hidden) { this.hide(); } else if (this.getTriggers().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 = `${style.left}px`; } const arrowStyles = this._adjustArrowPosition( this.props.placement, this.props.moveArrowTo, ); if (Object.keys(arrowStyles).length) { const arrow = tooltipNode.querySelector(`.${styles.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, }; } const container = this._getContainer(el); if (container !== document.body) { const containerRect = container.getBoundingClientRect(); const selfRect = el.getBoundingClientRect(); return { left: selfRect.left - containerRect.left + container.scrollLeft, top: selfRect.top - containerRect.top + container.scrollTop, width: selfRect.width, height: selfRect.height, }; } 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() { if (this.getTriggers().showTrigger === 'custom') { return; } this.show(); } _onTooltipContentLeave() { if (this.getTriggers().hideTrigger === 'custom') { return; } this._onMouseLeave(); } isShown() { return this.state.visible; } } export default Tooltip;