diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltip.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltip.tsx index 816038b817e..c5324bfa8aa 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltip.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltip.tsx @@ -1,7 +1,10 @@ import React from 'react'; +import { css } from '@emotion/css'; +import { Portal } from '../Portal/Portal'; import { Dimensions, TimeZone } from '@grafana/data'; import { FlotPosition } from '../Graph/types'; import { VizTooltipContainer } from './VizTooltipContainer'; +import { useStyles } from '../../themes'; import { TooltipDisplayMode } from './models.gen'; // Describes active dimensions user interacts with @@ -46,14 +49,30 @@ export interface VizTooltipProps { * @public */ export const VizTooltip: React.FC = ({ content, position, offset }) => { + const styles = useStyles(getStyles); if (position) { return ( - - {content} - + + + {content} + + ); } return null; }; VizTooltip.displayName = 'VizTooltip'; + +const getStyles = () => { + return { + portal: css` + position: absolute; + top: 0; + left: 0; + pointer-events: none; + width: 100%; + height: 100%; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx index 137045fc706..a27464f0c86 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContainer.tsx @@ -1,9 +1,10 @@ -import React, { useState, HTMLAttributes, useMemo } from 'react'; +import React, { useState, HTMLAttributes, useMemo, useRef, useLayoutEffect } from 'react'; import { css, cx } from '@emotion/css'; import { useStyles2 } from '../../themes'; import { getTooltipContainerStyles } from '../../themes/mixins'; -import { GrafanaTheme2 } from '@grafana/data'; -import { usePopper } from 'react-popper'; +import useWindowSize from 'react-use/lib/useWindowSize'; +import { Dimensions2D, GrafanaTheme2 } from '@grafana/data'; +import { calculateTooltipPosition } from './utils'; /** * @public @@ -24,47 +25,76 @@ export const VizTooltipContainer: React.FC = ({ className, ...otherProps }) => { - const [tooltipRef, setTooltipRef] = useState(null); - const virtualElement = useMemo( - () => ({ - getBoundingClientRect() { - return { top: positionY, left: positionX, bottom: positionY, right: positionX, width: 0, height: 0 }; - }, - }), - [positionY, positionX] - ); - const { styles: popperStyles, attributes } = usePopper(virtualElement, tooltipRef, { - placement: 'bottom-start', - modifiers: [ - { name: 'arrow', enabled: false }, - { - name: 'preventOverflow', - enabled: true, - options: { - altAxis: true, - rootBoundary: 'viewport', - }, - }, - { - name: 'offset', - options: { - offset: [offsetX, offsetY], - }, - }, - ], + const tooltipRef = useRef(null); + const [tooltipMeasurement, setTooltipMeasurement] = useState({ width: 0, height: 0 }); + const { width, height } = useWindowSize(); + const [placement, setPlacement] = useState({ + x: positionX + offsetX, + y: positionY + offsetY, }); + const resizeObserver = useMemo( + () => + // TS has hard time playing games with @types/resize-observer-browser, hence the ignore + // @ts-ignore + new ResizeObserver((entries) => { + for (let entry of entries) { + const tW = Math.floor(entry.contentRect.width + 2 * 8); // adding padding until Safari supports borderBoxSize + const tH = Math.floor(entry.contentRect.height + 2 * 8); + if (tooltipMeasurement.width !== tW || tooltipMeasurement.height !== tH) { + setTooltipMeasurement({ + width: tW, + height: tH, + }); + } + } + }), + [tooltipMeasurement] + ); + + useLayoutEffect(() => { + if (tooltipRef.current) { + resizeObserver.observe(tooltipRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [resizeObserver]); + + // Make sure tooltip does not overflow window + useLayoutEffect(() => { + if (tooltipRef && tooltipRef.current) { + const { x, y } = calculateTooltipPosition( + positionX, + positionY, + tooltipMeasurement.width, + tooltipMeasurement.height, + offsetX, + offsetY, + width, + height + ); + + setPlacement({ x, y }); + } + }, [width, height, positionX, offsetX, positionY, offsetY, tooltipMeasurement]); + const styles = useStyles2(getStyles); return (
diff --git a/packages/grafana-ui/src/components/VizTooltip/utils.test.ts b/packages/grafana-ui/src/components/VizTooltip/utils.test.ts new file mode 100644 index 00000000000..92c59cdba5c --- /dev/null +++ b/packages/grafana-ui/src/components/VizTooltip/utils.test.ts @@ -0,0 +1,165 @@ +import { calculateTooltipPosition } from './utils'; + +describe('utils', () => { + describe('calculateTooltipPosition', () => { + // let's pick some easy numbers for these, we shouldn't need to change them + const tooltipWidth = 100; + const tooltipHeight = 100; + const xOffset = 10; + const yOffset = 10; + const windowWidth = 200; + const windowHeight = 200; + + it('sticky positions the tooltip to the right if it would overflow at both ends but overflow to the left more', () => { + const xPos = 99; + const yPos = 50; + const result = calculateTooltipPosition( + xPos, + yPos, + tooltipWidth, + tooltipHeight, + xOffset, + yOffset, + windowWidth, + windowHeight + ); + expect(result).toEqual({ + x: 90, + y: 60, + }); + }); + + it('sticky positions the tooltip to the left if it would overflow at both ends but overflow to the right more', () => { + const xPos = 101; + const yPos = 50; + const result = calculateTooltipPosition( + xPos, + yPos, + tooltipWidth, + tooltipHeight, + xOffset, + yOffset, + windowWidth, + windowHeight + ); + expect(result).toEqual({ + x: 10, + y: 60, + }); + }); + + it('positions the tooltip to left of the cursor if it would overflow right', () => { + const xPos = 150; + const yPos = 50; + const result = calculateTooltipPosition( + xPos, + yPos, + tooltipWidth, + tooltipHeight, + xOffset, + yOffset, + windowWidth, + windowHeight + ); + expect(result).toEqual({ + x: 40, + y: 60, + }); + }); + + it('positions the tooltip to the right of the cursor if it would not overflow', () => { + const xPos = 50; + const yPos = 50; + const result = calculateTooltipPosition( + xPos, + yPos, + tooltipWidth, + tooltipHeight, + xOffset, + yOffset, + windowWidth, + windowHeight + ); + expect(result).toEqual({ + x: 60, + y: 60, + }); + }); + + it('sticky positions the tooltip to the bottom if it would overflow at both ends but overflow to the top more', () => { + const xPos = 50; + const yPos = 99; + const result = calculateTooltipPosition( + xPos, + yPos, + tooltipWidth, + tooltipHeight, + xOffset, + yOffset, + windowWidth, + windowHeight + ); + expect(result).toEqual({ + x: 60, + y: 90, + }); + }); + + it('sticky positions the tooltip to the top if it would overflow at both ends but overflow to the bottom more', () => { + const xPos = 50; + const yPos = 101; + const result = calculateTooltipPosition( + xPos, + yPos, + tooltipWidth, + tooltipHeight, + xOffset, + yOffset, + windowWidth, + windowHeight + ); + expect(result).toEqual({ + x: 60, + y: 10, + }); + }); + + it('positions the tooltip above the cursor if it would overflow at the bottom', () => { + const xPos = 50; + const yPos = 150; + const result = calculateTooltipPosition( + xPos, + yPos, + tooltipWidth, + tooltipHeight, + xOffset, + yOffset, + windowWidth, + windowHeight + ); + expect(result).toEqual({ + x: 60, + y: 40, + }); + }); + + it('positions the tooltip below the cursor if it would not overflow', () => { + const xPos = 50; + const yPos = 50; + const result = calculateTooltipPosition( + xPos, + yPos, + tooltipWidth, + tooltipHeight, + xOffset, + yOffset, + windowWidth, + windowHeight + ); + expect(result).toEqual({ + x: 60, + y: 60, + }); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/VizTooltip/utils.ts b/packages/grafana-ui/src/components/VizTooltip/utils.ts new file mode 100644 index 00000000000..6517962fc07 --- /dev/null +++ b/packages/grafana-ui/src/components/VizTooltip/utils.ts @@ -0,0 +1,40 @@ +export const calculateTooltipPosition = ( + xPos = 0, + yPos = 0, + tooltipWidth = 0, + tooltipHeight = 0, + xOffset = 0, + yOffset = 0, + windowWidth = 0, + windowHeight = 0 +) => { + let x = xPos; + let y = yPos; + + const overflowRight = Math.max(xPos + xOffset + tooltipWidth - (windowWidth - xOffset), 0); + const overflowLeft = Math.abs(Math.min(xPos - xOffset - tooltipWidth - xOffset, 0)); + const wouldOverflowRight = overflowRight > 0; + const wouldOverflowLeft = overflowLeft > 0; + + const overflowBelow = Math.max(yPos + yOffset + tooltipHeight - (windowHeight - yOffset), 0); + const overflowAbove = Math.abs(Math.min(yPos - yOffset - tooltipHeight - yOffset, 0)); + const wouldOverflowBelow = overflowBelow > 0; + const wouldOverflowAbove = overflowAbove > 0; + + if (wouldOverflowRight && wouldOverflowLeft) { + x = overflowRight > overflowLeft ? xOffset : windowWidth - xOffset - tooltipWidth; + } else if (wouldOverflowRight) { + x = xPos - xOffset - tooltipWidth; + } else { + x = xPos + xOffset; + } + + if (wouldOverflowBelow && wouldOverflowAbove) { + y = overflowBelow > overflowAbove ? yOffset : windowHeight - yOffset - tooltipHeight; + } else if (wouldOverflowBelow) { + y = yPos - yOffset - tooltipHeight; + } else { + y = yPos + yOffset; + } + return { x, y }; +};