diff options
Diffstat (limited to 'server/sonar-ui-common/components/controls/Tooltip.tsx')
-rw-r--r-- | server/sonar-ui-common/components/controls/Tooltip.tsx | 407 |
1 files changed, 407 insertions, 0 deletions
diff --git a/server/sonar-ui-common/components/controls/Tooltip.tsx b/server/sonar-ui-common/components/controls/Tooltip.tsx new file mode 100644 index 00000000000..8ac9f8711bb --- /dev/null +++ b/server/sonar-ui-common/components/controls/Tooltip.tsx @@ -0,0 +1,407 @@ +/* + * Sonar UI Common + * Copyright (C) 2019-2020 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { throttle } from 'lodash'; +import * as React from 'react'; +import { createPortal, findDOMNode } from 'react-dom'; +import ThemeContext from '../theme'; +import ScreenPositionFixer from './ScreenPositionFixer'; +import './Tooltip.css'; + +export type Placement = 'bottom' | 'right' | 'left' | 'top'; + +export interface TooltipProps { + classNameSpace?: string; + children: React.ReactElement<{}>; + mouseEnterDelay?: number; + mouseLeaveDelay?: number; + onShow?: () => void; + onHide?: () => void; + overlay: React.ReactNode; + placement?: Placement; + visible?: boolean; +} + +interface Measurements { + height: number; + left: number; + top: number; + width: number; +} + +interface OwnState { + flipped: boolean; + placement?: Placement; + visible: boolean; +} + +type State = OwnState & Partial<Measurements>; + +const FLIP_MAP: { [key in Placement]: Placement } = { + left: 'right', + right: 'left', + top: 'bottom', + bottom: 'top', +}; + +function isMeasured(state: State): state is OwnState & Measurements { + return state.height !== undefined; +} + +export default function Tooltip(props: TooltipProps) { + // overlay is a ReactNode, so it can be `undefined` or `null` + // this allows to easily render a tooltip conditionally + // more generaly we avoid rendering empty tooltips + return props.overlay != null && props.overlay !== '' ? ( + <TooltipInner {...props} /> + ) : ( + props.children + ); +} + +export class TooltipInner extends React.Component<TooltipProps, State> { + throttledPositionTooltip: () => void; + mouseEnterTimeout?: number; + mouseLeaveTimeout?: number; + tooltipNode?: HTMLElement | null; + mounted = false; + mouseIn = false; + + static defaultProps = { + mouseEnterDelay: 0.1, + }; + + constructor(props: TooltipProps) { + super(props); + this.state = { + flipped: false, + placement: props.placement, + visible: props.visible !== undefined ? props.visible : false, + }; + this.throttledPositionTooltip = throttle(this.positionTooltip, 10); + } + + componentDidMount() { + this.mounted = true; + if (this.props.visible === true) { + this.positionTooltip(); + this.addEventListeners(); + } + } + + componentDidUpdate(prevProps: TooltipProps, prevState: State) { + if (this.props.placement !== prevProps.placement) { + this.setState({ placement: this.props.placement }); + // Break. This will trigger a new componentDidUpdate() call, so the below + // positionTooltip() call will be correct. Otherwise, it might not use + // the new state.placement value. + return; + } + + if ( + // opens + (this.props.visible === true && !prevProps.visible) || + (this.props.visible === undefined && + this.state.visible === true && + prevState.visible === false) + ) { + this.positionTooltip(); + this.addEventListeners(); + } else if ( + // closes + (!this.props.visible && prevProps.visible === true) || + (this.props.visible === undefined && + this.state.visible === false && + prevState.visible === true) + ) { + this.clearPosition(); + this.removeEventListeners(); + } + } + + componentWillUnmount() { + this.mounted = false; + this.removeEventListeners(); + this.clearTimeouts(); + } + + static contextType = ThemeContext; + + addEventListeners = () => { + window.addEventListener('resize', this.throttledPositionTooltip); + window.addEventListener('scroll', this.throttledPositionTooltip); + }; + + removeEventListeners = () => { + window.removeEventListener('resize', this.throttledPositionTooltip); + window.removeEventListener('scroll', this.throttledPositionTooltip); + }; + + clearTimeouts = () => { + window.clearTimeout(this.mouseEnterTimeout); + window.clearTimeout(this.mouseLeaveTimeout); + }; + + isVisible = () => { + return this.props.visible !== undefined ? this.props.visible : this.state.visible; + }; + + getPlacement = (): Placement => { + return this.state.placement || 'bottom'; + }; + + tooltipNodeRef = (node: HTMLElement | null) => { + this.tooltipNode = node; + }; + + adjustArrowPosition = ( + placement: Placement, + { leftFix, topFix }: { leftFix: number; topFix: number } + ) => { + switch (placement) { + case 'left': + case 'right': + return { marginTop: -topFix }; + default: + return { marginLeft: -leftFix }; + } + }; + + positionTooltip = () => { + // `findDOMNode(this)` will search for the DOM node for the current component + // first it will find a React.Fragment (see `render`), + // so it will get the DOM node of the first child, i.e. DOM node of `this.props.children` + // docs: https://reactjs.org/docs/refs-and-the-dom.html#exposing-dom-refs-to-parent-components + + // eslint-disable-next-line react/no-find-dom-node + const toggleNode = findDOMNode(this); + + if (toggleNode && toggleNode instanceof Element && this.tooltipNode) { + const toggleRect = toggleNode.getBoundingClientRect(); + const tooltipRect = this.tooltipNode.getBoundingClientRect(); + const { width, height } = tooltipRect; + + let left = 0; + let top = 0; + + switch (this.getPlacement()) { + case 'bottom': + left = toggleRect.left + toggleRect.width / 2 - width / 2; + top = toggleRect.top + toggleRect.height; + break; + case 'top': + left = toggleRect.left + toggleRect.width / 2 - width / 2; + top = toggleRect.top - height; + break; + case 'right': + left = toggleRect.left + toggleRect.width; + top = toggleRect.top + toggleRect.height / 2 - height / 2; + break; + case 'left': + left = toggleRect.left - width; + top = toggleRect.top + toggleRect.height / 2 - height / 2; + break; + } + + // save width and height (and later set in `render`) to avoid resizing the tooltip element, + // when it's placed close to the window edge + this.setState({ + left: window.pageXOffset + left, + top: window.pageYOffset + top, + width, + height, + }); + } + }; + + clearPosition = () => { + this.setState({ + flipped: false, + left: undefined, + top: undefined, + width: undefined, + height: undefined, + placement: this.props.placement, + }); + }; + + handleMouseEnter = () => { + this.mouseEnterTimeout = window.setTimeout(() => { + // for some reason even after the `this.mouseEnterTimeout` is cleared, it still triggers + // to workaround this issue, check that its value is not `undefined` + // (if it's `undefined`, it means the timer has been reset) + if ( + this.mounted && + this.props.visible === undefined && + this.mouseEnterTimeout !== undefined + ) { + this.setState({ visible: true }); + } + }, (this.props.mouseEnterDelay || 0) * 1000); + + if (this.props.onShow) { + this.props.onShow(); + } + }; + + handleMouseLeave = () => { + if (this.mouseEnterTimeout !== undefined) { + window.clearTimeout(this.mouseEnterTimeout); + this.mouseEnterTimeout = undefined; + } + + if (!this.mouseIn) { + this.mouseLeaveTimeout = window.setTimeout(() => { + if (this.mounted && this.props.visible === undefined && !this.mouseIn) { + this.setState({ visible: false }); + } + }, (this.props.mouseLeaveDelay || 0) * 1000); + + if (this.props.onHide) { + this.props.onHide(); + } + } + }; + + handleOverlayMouseEnter = () => { + this.mouseIn = true; + }; + + handleOverlayMouseLeave = () => { + this.mouseIn = false; + this.handleMouseLeave(); + }; + + needsFlipping = (leftFix: number, topFix: number) => { + // We can live with a tooltip that's slightly positioned over the toggle + // node. Only trigger if it really starts overlapping, as the re-positioning + // is quite expensive, needing 2 re-renders. + const threshold = this.context.rawSizes.grid; + switch (this.getPlacement()) { + case 'left': + case 'right': + return Math.abs(leftFix) > threshold; + case 'top': + case 'bottom': + return Math.abs(topFix) > threshold; + } + return false; + }; + + renderActual = ({ leftFix = 0, topFix = 0 }) => { + if ( + !this.state.flipped && + (leftFix !== 0 || topFix !== 0) && + this.needsFlipping(leftFix, topFix) + ) { + // Update state in a render function... Not a good idea, but we need to + // render in order to know if we need to flip... To prevent React from + // complaining, we update the state using a setTimeout() call. + setTimeout(() => { + this.setState( + ({ placement = 'bottom' }) => ({ + flipped: true, + // Set height to undefined to force ScreenPositionFixer to + // re-compute our positioning. + height: undefined, + placement: FLIP_MAP[placement], + }), + () => { + if (this.state.visible) { + // Force a re-positioning, as "only" updating the state doesn't + // recompute the position, only re-renders with the previous + // position (which is no longer correct). + this.positionTooltip(); + } + } + ); + }, 1); + return null; + } + + const { classNameSpace = 'tooltip' } = this.props; + const placement = this.getPlacement(); + const style = isMeasured(this.state) + ? { + left: this.state.left + leftFix, + top: this.state.top + topFix, + width: this.state.width, + height: this.state.height, + } + : undefined; + + return ( + <div + className={`${classNameSpace} ${placement}`} + onMouseEnter={this.handleOverlayMouseEnter} + onMouseLeave={this.handleOverlayMouseLeave} + ref={this.tooltipNodeRef} + style={style}> + <div className={`${classNameSpace}-inner`}>{this.props.overlay}</div> + <div + className={`${classNameSpace}-arrow`} + style={ + isMeasured(this.state) + ? this.adjustArrowPosition(placement, { leftFix, topFix }) + : undefined + } + /> + </div> + ); + }; + + render() { + return ( + <> + {React.cloneElement(this.props.children, { + onMouseEnter: this.handleMouseEnter, + onMouseLeave: this.handleMouseLeave, + })} + {this.isVisible() && ( + <TooltipPortal> + <ScreenPositionFixer ready={isMeasured(this.state)}> + {this.renderActual} + </ScreenPositionFixer> + </TooltipPortal> + )} + </> + ); + } +} + +class TooltipPortal extends React.Component { + el: HTMLElement; + + constructor(props: {}) { + super(props); + this.el = document.createElement('div'); + } + + componentDidMount() { + document.body.appendChild(this.el); + } + + componentWillUnmount() { + document.body.removeChild(this.el); + } + + render() { + return createPortal(this.props.children, this.el); + } +} |