diff --git a/src/components/Tooltip/Tooltip.test.tsx b/src/components/Tooltip/Tooltip.test.tsx index e8f307d9b8..10f273e79a 100644 --- a/src/components/Tooltip/Tooltip.test.tsx +++ b/src/components/Tooltip/Tooltip.test.tsx @@ -2,7 +2,7 @@ import React from 'react' import { fireEvent, render, screen } from '@testing-library/react' import { Tooltip } from './Tooltip' -import { isElementInViewport } from './utils' +import { isElementInViewport, calculateMarginOffset } from './utils' jest.mock('./utils') @@ -11,6 +11,10 @@ const mockedIsElementInViewport = isElementInViewport as jest.MockedFunction< > mockedIsElementInViewport.mockReturnValue(true) +const mockedCalculateMarginOffset = + calculateMarginOffset as jest.MockedFunction +mockedCalculateMarginOffset.mockReturnValue(100) + describe('Tooltip component', () => { beforeEach(jest.clearAllMocks) @@ -168,13 +172,17 @@ describe('Tooltip component', () => { JSX.IntrinsicElements['a'] & React.RefAttributes - const CustomLink: React.ForwardRefExoticComponent = React.forwardRef( - ({ to, className, children, ...tooltipProps }: CustomLinkProps, ref) => ( - - {children} - + const CustomLink: React.ForwardRefExoticComponent = + React.forwardRef( + ( + { to, className, children, ...tooltipProps }: CustomLinkProps, + ref + ) => ( + + {children} + + ) ) - ) CustomLink.displayName = 'custom link' @@ -218,40 +226,26 @@ describe('Tooltip component', () => { ) const triggerEl = screen.getByTestId('triggerElement') + const bodyEl = screen.getByTestId('tooltipBody') + jest.spyOn(triggerEl, 'offsetHeight', 'get').mockReturnValue(250) jest.spyOn(triggerEl, 'offsetWidth', 'get').mockReturnValue(350) jest.spyOn(triggerEl, 'offsetLeft', 'get').mockReturnValue(100) - }) - - it('positions on the top', () => { - fireEvent.mouseEnter(screen.getByTestId('triggerElement')) - const bodyEl = screen.queryByRole('tooltip') - expect(bodyEl).toHaveClass('usa-tooltip__body--top') - expect(bodyEl).toHaveStyle('margin-left: 275px') + jest.spyOn(bodyEl, 'offsetHeight', 'get').mockReturnValue(225) + jest.spyOn(bodyEl, 'offsetWidth', 'get').mockReturnValue(300) }) - it('adds the wrap class if the width is outside the viewport', () => { - mockedIsElementInViewport.mockReturnValueOnce(true) - mockedIsElementInViewport.mockReturnValueOnce(false) - + it('positions on the top', () => { fireEvent.mouseEnter(screen.getByTestId('triggerElement')) const bodyEl = screen.queryByRole('tooltip') expect(bodyEl).toHaveClass('usa-tooltip__body--top') - expect(bodyEl).toHaveClass('usa-tooltip__body--wrap') - }) - - it('positions to the bottom if the height is outside the viewport', () => { - mockedIsElementInViewport.mockReturnValueOnce(false) - mockedIsElementInViewport.mockReturnValueOnce(false) - - fireEvent.mouseEnter(screen.getByTestId('triggerElement')) - - const bodyEl = screen.queryByRole('tooltip') - expect(bodyEl).toHaveClass('usa-tooltip__body--bottom') - expect(bodyEl).toHaveClass('usa-tooltip__body--wrap') - expect(bodyEl).toHaveStyle('margin-left: 275px') + expect(bodyEl).toHaveStyle({ + left: '50%', + top: '-5px', + margin: '-100px 0 0 -50px', + }) }) }) @@ -266,40 +260,25 @@ describe('Tooltip component', () => { ) const triggerEl = screen.getByTestId('triggerElement') + const bodyEl = screen.getByTestId('tooltipBody') + jest.spyOn(triggerEl, 'offsetHeight', 'get').mockReturnValue(250) jest.spyOn(triggerEl, 'offsetWidth', 'get').mockReturnValue(350) jest.spyOn(triggerEl, 'offsetLeft', 'get').mockReturnValue(100) - }) - - it('positions on the bottom', () => { - fireEvent.mouseEnter(screen.getByTestId('triggerElement')) - const bodyEl = screen.queryByRole('tooltip') - expect(bodyEl).toHaveClass('usa-tooltip__body--bottom') - expect(bodyEl).toHaveStyle('margin-left: 275px') + jest.spyOn(bodyEl, 'offsetHeight', 'get').mockReturnValue(225) + jest.spyOn(bodyEl, 'offsetWidth', 'get').mockReturnValue(300) }) - it('adds the wrap class if the width is outside the viewport', () => { - mockedIsElementInViewport.mockReturnValueOnce(true) - mockedIsElementInViewport.mockReturnValueOnce(false) - + it('positions on the bottom', () => { fireEvent.mouseEnter(screen.getByTestId('triggerElement')) const bodyEl = screen.queryByRole('tooltip') expect(bodyEl).toHaveClass('usa-tooltip__body--bottom') - expect(bodyEl).toHaveClass('usa-tooltip__body--wrap') - }) - - it('positions to the top if the height is outside the viewport', () => { - mockedIsElementInViewport.mockReturnValueOnce(false) - mockedIsElementInViewport.mockReturnValueOnce(false) - - fireEvent.mouseEnter(screen.getByTestId('triggerElement')) - - const bodyEl = screen.queryByRole('tooltip') - expect(bodyEl).toHaveClass('usa-tooltip__body--top') - expect(bodyEl).toHaveClass('usa-tooltip__body--wrap') - expect(bodyEl).toHaveStyle('margin-left: 275px') + expect(bodyEl).toHaveStyle({ + left: '50%', + margin: '5px 0 0 -50px', + }) }) }) @@ -314,9 +293,14 @@ describe('Tooltip component', () => { ) const triggerEl = screen.getByTestId('triggerElement') + const bodyEl = screen.getByTestId('tooltipBody') + jest.spyOn(triggerEl, 'offsetHeight', 'get').mockReturnValue(250) jest.spyOn(triggerEl, 'offsetWidth', 'get').mockReturnValue(350) jest.spyOn(triggerEl, 'offsetLeft', 'get').mockReturnValue(100) + + jest.spyOn(bodyEl, 'offsetHeight', 'get').mockReturnValue(225) + jest.spyOn(bodyEl, 'offsetWidth', 'get').mockReturnValue(300) }) it('positions on the right', () => { @@ -324,27 +308,11 @@ describe('Tooltip component', () => { const bodyEl = screen.queryByRole('tooltip') expect(bodyEl).toHaveClass('usa-tooltip__body--right') - expect(bodyEl).toHaveStyle('margin-bottom: 0px') - expect(bodyEl).toHaveStyle('margin-left: 457px') - }) - - it('positions to the left if the width is outside the viewport', () => { - mockedIsElementInViewport.mockReturnValueOnce(false) - - fireEvent.mouseEnter(screen.getByTestId('triggerElement')) - - const bodyEl = screen.queryByRole('tooltip') - expect(bodyEl).toHaveClass('usa-tooltip__body--left') - }) - - it('positions to the top if the width is still outside the viewport', () => { - mockedIsElementInViewport.mockReturnValueOnce(false) - mockedIsElementInViewport.mockReturnValueOnce(false) - - fireEvent.mouseEnter(screen.getByTestId('triggerElement')) - - const bodyEl = screen.queryByRole('tooltip') - expect(bodyEl).toHaveClass('usa-tooltip__body--top') + expect(bodyEl).toHaveStyle({ + top: '50%', + left: '455px', + margin: '-50px 0 0 0', + }) }) }) @@ -359,11 +327,14 @@ describe('Tooltip component', () => { ) const triggerEl = screen.getByTestId('triggerElement') - const bodyEl = screen.getByRole('tooltip', { hidden: true }) + const bodyEl = screen.getByTestId('tooltipBody') + jest.spyOn(triggerEl, 'offsetHeight', 'get').mockReturnValue(250) jest.spyOn(triggerEl, 'offsetWidth', 'get').mockReturnValue(350) jest.spyOn(triggerEl, 'offsetLeft', 'get').mockReturnValue(100) - jest.spyOn(bodyEl, 'offsetWidth', 'get').mockReturnValue(150) + + jest.spyOn(bodyEl, 'offsetHeight', 'get').mockReturnValue(225) + jest.spyOn(bodyEl, 'offsetWidth', 'get').mockReturnValue(300) }) it('positions on the left', () => { @@ -371,27 +342,114 @@ describe('Tooltip component', () => { const bodyEl = screen.queryByRole('tooltip') expect(bodyEl).toHaveClass('usa-tooltip__body--left') - expect(bodyEl).toHaveStyle('margin-bottom: 0px') - expect(bodyEl).toHaveStyle('margin-left: -57px') + expect(bodyEl).toHaveStyle({ + top: '50%', + left: '-5px', + margin: '-50px 0 0 -100px', + }) }) + }) + }) - it('positions to the right if the width is outside the viewport', () => { - mockedIsElementInViewport.mockReturnValueOnce(false) + describe('finding the best position', () => { + beforeEach(() => { + jest.clearAllMocks() - fireEvent.mouseEnter(screen.getByTestId('triggerElement')) + render( + + My Tooltip + + ) - const bodyEl = screen.queryByRole('tooltip') - expect(bodyEl).toHaveClass('usa-tooltip__body--right') + const triggerEl = screen.getByTestId('triggerElement') + const bodyEl = screen.getByTestId('tooltipBody') + + jest.spyOn(triggerEl, 'offsetHeight', 'get').mockReturnValue(250) + jest.spyOn(triggerEl, 'offsetWidth', 'get').mockReturnValue(350) + jest.spyOn(triggerEl, 'offsetLeft', 'get').mockReturnValue(100) + + jest.spyOn(bodyEl, 'offsetHeight', 'get').mockReturnValue(225) + jest.spyOn(bodyEl, 'offsetWidth', 'get').mockReturnValue(300) + }) + + it('tries to position to the top first', () => { + mockedIsElementInViewport.mockReturnValueOnce(false) // Intended position + + fireEvent.mouseEnter(screen.getByTestId('triggerElement')) + + const bodyEl = screen.queryByRole('tooltip') + expect(bodyEl).toHaveClass('usa-tooltip__body--top') + expect(bodyEl).toHaveStyle({ + left: '50%', + top: '-5px', + margin: '-100px 0 0 -50px', }) + }) - it('positions to the top if the width is still outside the viewport', () => { - mockedIsElementInViewport.mockReturnValueOnce(false) - mockedIsElementInViewport.mockReturnValueOnce(false) + it('tries to position to the bottom second', () => { + mockedIsElementInViewport.mockReturnValueOnce(false) // Intended position + mockedIsElementInViewport.mockReturnValueOnce(false) // Tried top - fireEvent.mouseEnter(screen.getByTestId('triggerElement')) + fireEvent.mouseEnter(screen.getByTestId('triggerElement')) - const bodyEl = screen.queryByRole('tooltip') - expect(bodyEl).toHaveClass('usa-tooltip__body--top') + const bodyEl = screen.queryByRole('tooltip') + expect(bodyEl).toHaveClass('usa-tooltip__body--bottom') + expect(bodyEl).toHaveStyle({ + left: '50%', + margin: '5px 0 0 -50px', + }) + }) + + it('tries to position to the right third', () => { + mockedIsElementInViewport.mockReturnValueOnce(false) // Intended position + mockedIsElementInViewport.mockReturnValueOnce(false) // Tried top + mockedIsElementInViewport.mockReturnValueOnce(false) // Tried bottom + + fireEvent.mouseEnter(screen.getByTestId('triggerElement')) + + const bodyEl = screen.queryByRole('tooltip') + expect(bodyEl).toHaveClass('usa-tooltip__body--right') + expect(bodyEl).toHaveStyle({ + top: '50%', + left: '455px', + margin: '-50px 0 0 0', + }) + }) + + it('tries to position to the left fourth', () => { + mockedIsElementInViewport.mockReturnValueOnce(false) // Intended position + mockedIsElementInViewport.mockReturnValueOnce(false) // Tried top + mockedIsElementInViewport.mockReturnValueOnce(false) // Tried bottom + mockedIsElementInViewport.mockReturnValueOnce(false) // Tried right + + fireEvent.mouseEnter(screen.getByTestId('triggerElement')) + + const bodyEl = screen.queryByRole('tooltip') + expect(bodyEl).toHaveClass('usa-tooltip__body--left') + expect(bodyEl).toHaveStyle({ + top: '50%', + left: '-5px', + margin: '-50px 0 0 -100px', + }) + }) + + it('adds the wrap class if none of the positions worked', () => { + mockedIsElementInViewport.mockReturnValueOnce(false) // Intended position + mockedIsElementInViewport.mockReturnValueOnce(false) // Tried top + mockedIsElementInViewport.mockReturnValueOnce(false) // Tried bottom + mockedIsElementInViewport.mockReturnValueOnce(false) // Tried right + mockedIsElementInViewport.mockReturnValueOnce(false) // Tried left + mockedIsElementInViewport.mockReturnValueOnce(false) // Tried intended again + + fireEvent.mouseEnter(screen.getByTestId('triggerElement')) + + const bodyEl = screen.queryByRole('tooltip') + expect(bodyEl).toHaveClass('usa-tooltip__body--top') + expect(bodyEl).toHaveClass('usa-tooltip__body--wrap') + expect(bodyEl).toHaveStyle({ + left: '50%', + top: '-5px', + margin: '-100px 0 0 -50px', }) }) }) diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx index 5e7a7bcc86..6805bcfbb4 100644 --- a/src/components/Tooltip/Tooltip.tsx +++ b/src/components/Tooltip/Tooltip.tsx @@ -8,7 +8,8 @@ import React, { useState, } from 'react' import classnames from 'classnames' -import { isElementInViewport } from './utils' + +import { isElementInViewport, calculateMarginOffset } from './utils' type TooltipProps = { label: string @@ -33,14 +34,12 @@ export function isCustomProps( } const TRIANGLE_SIZE = 5 -const SPACER = 2 export function Tooltip(props: DefaultTooltipProps): ReactElement export function Tooltip(props: CustomTooltipProps): ReactElement export function Tooltip( props: DefaultTooltipProps | CustomTooltipProps ): ReactElement { - const wrapperRef = useRef(null) const triggerElementRef = useRef(null) const tooltipBodyRef = useRef(null) const tooltipID = useRef( @@ -52,163 +51,127 @@ export function Tooltip( const [effectivePosition, setEffectivePosition] = useState< 'top' | 'bottom' | 'left' | 'right' | undefined >(undefined) + const [positioningAttempts, setPositionAttempts] = useState(0) const [wrapTooltip, setWrapTooltip] = useState(false) const [positionStyles, setPositionStyles] = useState({}) const { position, wrapperclasses, className } = props + const positionTop = (e: HTMLElement, triggerEl: HTMLElement): void => { + const topMargin = calculateMarginOffset('top', e.offsetHeight, triggerEl) + const leftMargin = calculateMarginOffset('left', e.offsetWidth, triggerEl) + + setEffectivePosition('top') + setPositionStyles({ + left: `50%`, + top: `-${TRIANGLE_SIZE}px`, + margin: `-${topMargin}px 0 0 -${leftMargin / 2}px`, + }) + } + + const positionBottom = (e: HTMLElement, triggerEl: HTMLElement): void => { + const leftMargin = calculateMarginOffset('left', e.offsetWidth, triggerEl) + + setEffectivePosition('bottom') + setPositionStyles({ + left: `50%`, + margin: `${TRIANGLE_SIZE}px 0 0 -${leftMargin / 2}px`, + }) + } + + const positionRight = (e: HTMLElement, triggerEl: HTMLElement): void => { + const topMargin = calculateMarginOffset('top', e.offsetHeight, triggerEl) + + setEffectivePosition('right') + setPositionStyles({ + top: `50%`, + left: `${triggerEl.offsetLeft + triggerEl.offsetWidth + TRIANGLE_SIZE}px`, + margin: `-${topMargin / 2}px 0 0 0`, + }) + } + + const positionLeft = (e: HTMLElement, triggerEl: HTMLElement): void => { + const topMargin = calculateMarginOffset('top', e.offsetHeight, triggerEl) + const leftMargin = calculateMarginOffset( + 'left', + triggerEl.offsetLeft > e.offsetWidth + ? triggerEl.offsetLeft - e.offsetWidth + : e.offsetWidth, + triggerEl + ) + + setEffectivePosition('left') + setPositionStyles({ + top: `50%`, + left: `-${TRIANGLE_SIZE}px`, + margin: `-${topMargin / 2}px 0 0 ${ + triggerEl.offsetLeft > e.offsetWidth ? leftMargin : -leftMargin + }px`, + }) + } + + const positions = [positionTop, positionBottom, positionRight, positionLeft] + const MAX_ATTEMPTS = positions.length + useEffect(() => { - if (effectivePosition === 'top' || effectivePosition === 'bottom') { - if ( - tooltipBodyRef.current && - !isElementInViewport(tooltipBodyRef.current) - ) { - setWrapTooltip(true) + // When position/styles change, check if in viewport + if (isVisible && triggerElementRef.current && tooltipBodyRef.current) { + const tooltipTrigger = triggerElementRef.current + const tooltipBody = tooltipBodyRef.current + + const isInViewport = isElementInViewport(tooltipBody) + + if (isInViewport) { + // We're good, show the tooltip + setIsShown(true) + } else { + // Try the next position + const attempt = positioningAttempts + if (attempt < MAX_ATTEMPTS || wrapTooltip === false) { + setPositionAttempts((a) => a + 1) + + if (attempt < MAX_ATTEMPTS) { + const pos = positions[parseInt(`${attempt}`)] + pos(tooltipBody, tooltipTrigger) + } else { + // Try wrapping + setWrapTooltip(true) + setPositionAttempts(0) + } + } else { + // No visible position found - this may mean your tooltip contents is too long! + console.warn( + 'No visible position found - this may mean your tooltip contents is too long!' + ) + } } } - }, [effectivePosition]) - - useEffect(() => { - if (isVisible) setIsShown(true) - }, [effectivePosition, positionStyles]) + }, [effectivePosition, positionStyles, wrapTooltip]) useEffect(() => { if (!isVisible) { // Hide tooltip setIsShown(false) setWrapTooltip(false) + setPositionAttempts(0) } else { - if ( - triggerElementRef.current && - tooltipBodyRef.current && - wrapperRef.current - ) { + // Show tooltip + if (triggerElementRef.current && tooltipBodyRef.current) { const tooltipTrigger = triggerElementRef.current const tooltipBody = tooltipBodyRef.current - const wrapper = wrapperRef.current - - // Calculate sizing and adjustments for positioning - const tooltipWidth = tooltipTrigger.offsetWidth - const tooltipHeight = tooltipTrigger.offsetHeight - const offsetForTopMargin = Number.parseInt( - window - .getComputedStyle(tooltipTrigger) - .getPropertyValue('margin-top'), - 10 - ) - const offsetForBottomMargin = Number.parseInt( - window - .getComputedStyle(tooltipTrigger) - .getPropertyValue('margin-bottom'), - 10 - ) - const offsetForTopPadding = Number.parseInt( - window.getComputedStyle(wrapper).getPropertyValue('padding-top'), - 10 - ) - const offsetForBottomPadding = Number.parseInt( - window.getComputedStyle(wrapper).getPropertyValue('padding-bottom'), - 10 - ) - // issue dealing with null here - const offsetForTooltipBodyHeight = Number.parseInt( - window.getComputedStyle(tooltipBody).getPropertyValue('height'), - 10 - ) - const leftOffset = tooltipTrigger.offsetLeft - const tooltipBodyWidth = tooltipBody.offsetWidth - const adjustHorizontalCenter = tooltipWidth / 2 + leftOffset - const adjustToEdgeX = tooltipWidth + TRIANGLE_SIZE + SPACER - const adjustToEdgeY = tooltipHeight + TRIANGLE_SIZE + SPACER - - const positionTop = (): void => { - setEffectivePosition('top') - setPositionStyles({ - marginLeft: `${adjustHorizontalCenter}px`, - marginBottom: `${ - adjustToEdgeY + offsetForBottomMargin + offsetForBottomPadding - }px`, - }) - } - - const positionBottom = (): void => { - setEffectivePosition('bottom') - setPositionStyles({ - marginLeft: `${adjustHorizontalCenter}px`, - marginTop: `${ - adjustToEdgeY + offsetForTopMargin + offsetForTopPadding - }px`, - }) - } - - const positionRight = (): void => { - setEffectivePosition('right') - setPositionStyles({ - marginBottom: '0', - marginLeft: `${adjustToEdgeX + leftOffset}px`, - bottom: `${ - (tooltipHeight - offsetForTooltipBodyHeight) / 2 + - offsetForBottomMargin + - offsetForBottomPadding - }px`, - }) - } - - const positionLeft = (): void => { - setEffectivePosition('left') - setPositionStyles({ - marginBottom: '0', - marginLeft: - leftOffset > tooltipBodyWidth - ? `${ - leftOffset - tooltipBodyWidth - (TRIANGLE_SIZE + SPACER) - }px` - : `-${ - tooltipBodyWidth - leftOffset + (TRIANGLE_SIZE + SPACER) - }px`, - bottom: `${ - (tooltipHeight - offsetForTooltipBodyHeight) / 2 + - offsetForBottomMargin + - offsetForBottomPadding - }px`, - }) - } - /** - * We try to set the position based on the - * original intention, but make adjustments - * if the element is clipped out of the viewport - */ switch (position) { case 'top': - positionTop() - if (!isElementInViewport(tooltipBody)) { - positionBottom() - } + positionTop(tooltipBody, tooltipTrigger) break case 'bottom': - positionBottom() - if (!isElementInViewport(tooltipBody)) { - positionTop() - } + positionBottom(tooltipBody, tooltipTrigger) break case 'right': - positionRight() - if (!isElementInViewport(tooltipBody)) { - positionLeft() - if (!isElementInViewport(tooltipBody)) { - positionTop() - } - } + positionRight(tooltipBody, tooltipTrigger) break case 'left': - positionLeft() - if (!isElementInViewport(tooltipBody)) { - positionRight() - if (!isElementInViewport(tooltipBody)) { - positionTop() - } - } + positionLeft(tooltipBody, tooltipTrigger) break default: @@ -240,7 +203,7 @@ export function Tooltip( if (isCustomProps(props)) { const { label, asCustom, children, ...remainingProps } = props - const customProps: FCProps = (remainingProps as unknown) as FCProps + const customProps: FCProps = remainingProps as unknown as FCProps const triggerClasses = classnames('usa-tooltip__trigger', className) @@ -265,10 +228,7 @@ export function Tooltip( ) return ( - + {triggerElement} ( ) return ( - +