123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436 |
- /*
- * SonarQube
- * Copyright (C) 2009-2022 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, uniqueId } from 'lodash';
- import * as React from 'react';
- import { createPortal, findDOMNode } from 'react-dom';
- import { rawSizes } from '../../app/theme';
- import EscKeydownHandler from './EscKeydownHandler';
- 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 generally, 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;
- id: string;
-
- 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.id = uniqueId('tooltip-');
- 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();
- }
-
- 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`). It will skip this, and
- // 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's 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 });
- }
- if (this.props.onHide && !this.mouseIn) {
- this.props.onHide();
- }
- }, (this.props.mouseLeaveDelay || 0) * 1000);
- }
- };
-
- handleFocus = () => {
- this.setState({ visible: true });
- if (this.props.onShow) {
- this.props.onShow();
- }
- };
-
- handleBlur = () => {
- if (this.mounted) {
- this.setState({ visible: false });
- 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 = 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 currentPlacement = 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} ${currentPlacement}`}
- onPointerEnter={this.handleOverlayMouseEnter}
- onPointerLeave={this.handleOverlayMouseLeave}
- ref={this.tooltipNodeRef}
- style={style}>
- <div className={`${classNameSpace}-inner`} id={this.id}>
- {this.props.overlay}
- </div>
- <div
- className={`${classNameSpace}-arrow`}
- style={
- isMeasured(this.state)
- ? this.adjustArrowPosition(currentPlacement, { leftFix, topFix })
- : undefined
- }
- />
- </div>
- );
- };
-
- render() {
- const isVisible = this.isVisible();
- return (
- <>
- {React.cloneElement(this.props.children, {
- onPointerEnter: this.handleMouseEnter,
- onPointerLeave: this.handleMouseLeave,
- onFocus: this.handleFocus,
- onBlur: this.handleBlur,
- tabIndex: 0,
- // aria-describedby is the semantically correct property to use, but it's not
- // always well supported. As a fallback, we use aria-labelledby as well.
- // See https://sarahmhigley.com/writing/tooltips-in-wcag-21/
- // See https://css-tricks.com/accessible-svgs/
- 'aria-describedby': isVisible ? this.id : undefined,
- 'aria-labelledby': isVisible ? this.id : undefined
- })}
- {isVisible && (
- <EscKeydownHandler onKeydown={this.handleBlur}>
- <TooltipPortal>
- <ScreenPositionFixer ready={isMeasured(this.state)}>
- {this.renderActual}
- </ScreenPositionFixer>
- </TooltipPortal>
- </EscKeydownHandler>
- )}
- </>
- );
- }
- }
-
- 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);
- }
- }
|