import React, { createRef, ReactElement } from 'react'; import { act, render, screen, waitFor, waitForElementToBeRemoved, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { axe } from 'jest-axe'; import { Icon } from '@leafygreen-ui/icon'; import CloudIcon from '@leafygreen-ui/icon/dist/Cloud'; import { HTMLElementProps, OneOf } from '@leafygreen-ui/lib'; import { RenderMode } from '@leafygreen-ui/popover'; import { transitionDuration } from '@leafygreen-ui/tokens'; import Tooltip from './Tooltip'; import { TooltipProps } from './Tooltip.types'; const buttonText = 'trigger button'; const tooltipTestId = 'tooltip-test-id'; const onClick = jest.fn(); function waitForTimeout(timeout = 500) { return new Promise(res => setTimeout(res, timeout)); } interface ButtonTestProps { [key: string]: any; } class ClassTrigger extends React.Component { render() { const { children } = this.props; return ( ); } } const FunctionTrigger = ({ children, ...rest }: HTMLElementProps<'div'>) => (
{buttonText} {children}
); const triggerTypes = [ { type: 'class', Trigger: ClassTrigger, }, { type: 'function', Trigger: FunctionTrigger, }, ]; function renderTooltip( props: Omit & OneOf< { renderMode?: 'portal'; portalClassName?: string }, { renderMode: 'inline' | 'top-layer' } > = {}, ) { const utils = render( <>
{buttonText}} data-testid={tooltipTestId} {...props} >
Tooltip Contents!
, ); const button = utils.getByText(buttonText); const backdrop = utils.getByTestId('backdrop'); return { ...utils, button, backdrop }; } beforeEach(() => { onClick.mockReset(); }); describe('packages/tooltip', () => { describe('a11y', () => { test('does not have basic accessibility issues', async () => { const { container } = renderTooltip({ triggerEvent: 'click' }); const results = await axe(container); expect(results).toHaveNoViolations(); let newResults = null as any; await act(async () => { await userEvent.click(screen.getByText(buttonText)); }); await act(async () => { newResults = await axe(container); }); expect(newResults).toHaveNoViolations(); }); }); describe('when uncontrolled', () => { test('responds to the `intialOpen` prop when set', () => { const { getByTestId } = renderTooltip({ initialOpen: true }); const tooltip = getByTestId(tooltipTestId); expect(tooltip).toBeInTheDocument(); }); test(`renders a button to the DOM with ${buttonText}`, () => { const { getByText } = renderTooltip(); expect(getByText(buttonText)).toBeInTheDocument(); }); test('when "triggerEvent" is set to click, clicking trigger opens and closes the tooltip', async () => { const { button, getByTestId } = renderTooltip({ triggerEvent: 'click', }); await userEvent.click(button); expect(onClick).toHaveBeenCalledTimes(1); const tooltip = getByTestId(tooltipTestId); // checking that in the Document, because in the document before opacity hits 1 await waitFor(() => tooltip); // Wait for tooltip delay await waitForTimeout(transitionDuration.slowest); expect(tooltip).toBeVisible(); // checking for visibility, because opacity changes before tooltip transitions out of the DOM await userEvent.click(button); await waitForElementToBeRemoved(tooltip); }); test('when "triggerEvent" is set to "hover", hovering on and off the trigger opens and closes the tooltip', async () => { const { getByTestId, queryByTestId, button } = renderTooltip({ triggerEvent: 'hover', }); await userEvent.hover(button); await waitFor(() => getByTestId(tooltipTestId)); expect(getByTestId(tooltipTestId)).toBeInTheDocument(); await userEvent.unhover(button); await waitForElementToBeRemoved(getByTestId(tooltipTestId)); expect(queryByTestId(tooltipTestId)).not.toBeInTheDocument(); }); async function testTriggerEventWhenDisabled( triggerEvent: 'hover' | 'click', ) { test(`when triggerEvent is "${triggerEvent}"`, async () => { const { queryByTestId, button } = renderTooltip({ triggerEvent, enabled: false, }); // Wait for 200ms to ensure enough time in case the element erroneously appears await act(async () => { if (triggerEvent === 'hover') { await userEvent.hover(button); } else { await userEvent.click(button); } await waitForTimeout(200); }); expect(queryByTestId(tooltipTestId)).not.toBeInTheDocument(); // The following test is largely here to ensure we don't somehow end up in a strange state where the element becomes visible once the mouse leaves. await act(async () => { if (triggerEvent === 'hover') { await userEvent.unhover(button); } else { await userEvent.click(button); } await waitForTimeout(200); }); expect(queryByTestId(tooltipTestId)).not.toBeInTheDocument(); }); } describe('tooltip does not open when enabled is "false"', () => { testTriggerEventWhenDisabled('hover'); testTriggerEventWhenDisabled('click'); }); test('tooltip closes when enabled is set to "false"', async () => { const { getByTestId, button, container } = renderTooltip({ triggerEvent: 'click', enabled: true, }); await userEvent.click(button); const tooltip = await waitFor(() => { const tooltip = getByTestId(tooltipTestId); expect(tooltip).toBeVisible(); return tooltip; }); render( <>
{buttonText}} data-testid={tooltipTestId} triggerEvent="click" enabled={false} >
Tooltip Contents!
, { container }, ); await waitFor(() => { expect(tooltip).not.toBeInTheDocument(); }); }); test('backdrop clicks close the tooltip', async () => { const { getByTestId, button, backdrop } = renderTooltip({ triggerEvent: 'click', }); await userEvent.click(button); const tooltip = getByTestId(tooltipTestId); expect(tooltip).toBeInTheDocument(); await userEvent.click(backdrop); await waitForElementToBeRemoved(tooltip); }); test('escape click closes tooltip', async () => { const { getByTestId, button } = renderTooltip({ triggerEvent: 'click', }); await userEvent.click(button); const tooltip = getByTestId(tooltipTestId); await act(async () => { waitForTimeout(transitionDuration.slowest); }); await waitFor(() => expect(tooltip).toBeVisible()); await userEvent.keyboard('{Escape}'); await waitForElementToBeRemoved(tooltip); }); test('when "shouldClose" prop is returns true', async () => { const { getByTestId, backdrop, button } = renderTooltip({ triggerEvent: 'click', shouldClose: () => true, }); await userEvent.click(button); const tooltip = getByTestId(tooltipTestId); await act(async () => { waitForTimeout(transitionDuration.slowest); }); await waitFor(() => expect(tooltip).toBeVisible()); await userEvent.click(backdrop); await waitForElementToBeRemoved(tooltip); }); test('when "shouldClose" prop is returns false', async () => { const { getByTestId, backdrop, button } = renderTooltip({ triggerEvent: 'click', shouldClose: () => false, }); await userEvent.click(button); const tooltip = getByTestId(tooltipTestId); await act(async () => { waitForTimeout(transitionDuration.slowest); }); await waitFor(() => expect(tooltip).toBeVisible()); await userEvent.click(backdrop); expect(tooltip).toBeVisible(); }); }); describe('when controlled', () => { const setOpen = jest.fn(); test('renders initial state based on "open" prop', async () => { const { getByTestId } = renderTooltip({ open: true, setOpen, }); await waitFor(() => expect(getByTestId(tooltipTestId)).toBeVisible()); }); test('onClick fires when trigger is clicked', async () => { const { button } = renderTooltip({ open: true, setOpen, }); await userEvent.click(button); expect(onClick).toHaveBeenCalled(); }); describe('clicking content inside of tooltip does not force tooltip to close', () => { function testCase(name: string, renderMode: RenderMode): void { test(`${name}`, async () => { const { button, getByTestId } = renderTooltip({ open: true, setOpen, renderMode, }); await userEvent.click(button); const tooltip = getByTestId(tooltipTestId); await waitFor(() => expect(tooltip).toBeVisible()); onClick.mockClear(); let clickTarget: HTMLElement = tooltip; while (![document.body, button].includes(clickTarget)) { await userEvent.click(clickTarget); expect(tooltip).toBeVisible(); expect(onClick).not.toHaveBeenCalled(); clickTarget = clickTarget.parentElement!; } }); } testCase('render inline', RenderMode.Inline); testCase('render portal', RenderMode.Portal); testCase('render top layer', RenderMode.TopLayer); }); }); describe.each(triggerTypes)(`when trigger is`, ({ type, Trigger }) => { describe(`a ${type} component`, () => { function renderTrigger(props = {}) { const utils = render( <>
{buttonText}} {...props}>
Tooltip Contents!
, ); const button = utils.getByTestId(`${type}-trigger`); const backdrop = utils.getByTestId('backdrop'); return { ...utils, button, backdrop }; } test('renders a button to the DOM with text', () => { const { button } = renderTrigger(); expect(button).toBeVisible(); expect(button).toHaveTextContent(buttonText); }); describe('triggerEvent is `click`', () => { test('click event triggers opening and closing of tooltip', async () => { const { button, getByTestId } = renderTrigger({ triggerEvent: 'click', }); await userEvent.click(button); const tooltip = getByTestId(tooltipTestId); expect(tooltip).toBeInTheDocument(); await userEvent.click(button); await waitForElementToBeRemoved(tooltip); }); test('clicking the backdrop closes the tooltip', async () => { const { button, getByTestId } = renderTrigger({ triggerEvent: 'click', }); await userEvent.click(button); const tooltip = getByTestId(tooltipTestId); const backdrop = getByTestId('backdrop'); await userEvent.click(backdrop); await waitForElementToBeRemoved(tooltip); }); }); describe('triggerEvent is `hover`', () => { test('hover event triggers opening and closing of tooltip', async () => { const { button, getByTestId } = renderTrigger({ triggerEvent: 'hover', }); await userEvent.hover(button); await waitFor(() => { const tooltip = getByTestId(tooltipTestId); expect(tooltip).toBeInTheDocument(); }); await userEvent.unhover(button); await waitForElementToBeRemoved(getByTestId(tooltipTestId)); }); test('clicking the backdrop does not close the tooltip', async () => { const { button, getByTestId } = renderTrigger({ triggerEvent: 'hover', }); await userEvent.hover(button); await waitFor(() => { const tooltip = getByTestId(tooltipTestId); expect(tooltip).toBeInTheDocument(); }); const backdrop = getByTestId('backdrop'); await userEvent.click(backdrop); expect(getByTestId(tooltipTestId)).toBeInTheDocument(); }); }); }); }); describe('when trigger is an inline function', () => { function renderInlineTrigger(props: TooltipProps = {}) { props.trigger = props.trigger ?? (({ children, ...rest }: ButtonTestProps) => ( )); const utils = render( <>
Tooltip Contents!
, ); const button = utils.getByTestId('inline-trigger'); const backdrop = utils.getByTestId('backdrop'); return { ...utils, button, backdrop }; } test(`renders a button to the DOM with ${buttonText}`, () => { const { button } = renderInlineTrigger(); expect(button).toBeInTheDocument(); }); test(`when "triggerEvent" is click, clicking triggers opening and closing of tooltip`, async () => { const { button, getByTestId } = renderInlineTrigger({ triggerEvent: 'click', }); await userEvent.click(button); const tooltip = getByTestId(tooltipTestId); expect(tooltip).toBeInTheDocument(); await userEvent.click(button); await waitForElementToBeRemoved(tooltip); }); test(`retains existing class names of trigger component`, () => { const TEST_CLASS_NAME = 'test-class-name'; const customTrigger = (props: ButtonTestProps) => ( ); const { button } = renderInlineTrigger({ trigger: customTrigger }); expect(button).toBeInTheDocument(); expect(button).toHaveClass(TEST_CLASS_NAME); }); }); describe('when trigger contains nested children', () => { interface ButtonProps { children: React.ReactNode; } function Button({ children, ...props }: ButtonProps) { return ( ); } function renderNestedTrigger(props = {}) { const utils = render( trigger } >
Tooltip!
, ); const button = utils.getByTestId('nested-trigger'); return { ...utils, button }; } test('renders trigger in document', () => { const { button } = renderNestedTrigger(); expect(button).toBeInTheDocument(); }); }); test('accepts a portalRef', () => { const portalContainer = document.createElement('div'); document.body.appendChild(portalContainer); const portalRef = createRef(); renderTooltip({ open: true, portalContainer, portalRef, renderMode: RenderMode.Portal, }); expect(portalRef.current).toBeDefined(); expect(portalRef.current).toBe(portalContainer); }); test(`does not portal popover content to end of DOM when renderMode=${RenderMode.Inline}`, () => { const { container } = renderTooltip({ open: true, renderMode: RenderMode.Inline, }); expect(container.innerHTML.includes(tooltipTestId)).toBeTruthy(); }); test(`portals popover content to end of DOM when renderMode=${RenderMode.Portal}`, () => { const { container, getByTestId } = renderTooltip({ open: true, renderMode: RenderMode.Portal, }); expect(container).not.toContain(getByTestId(tooltipTestId)); }); test(`does not portal popover content to end of DOM when renderMode=${RenderMode.TopLayer}`, () => { const { container } = renderTooltip({ open: true, renderMode: RenderMode.TopLayer, }); expect(container.innerHTML.includes(tooltipTestId)).toBe(true); }); test('applies "portalClassName" to root of portal', () => { const { getByTestId } = renderTooltip({ open: true, portalClassName: 'test-classname', renderMode: RenderMode.Portal, }); const matchedElements = document.querySelectorAll('body > .test-classname'); expect(matchedElements).toHaveLength(1); const portalRoot = matchedElements.item(0); expect(portalRoot).toContainElement(getByTestId(tooltipTestId)); }); describe('Renders warning when', () => { const expectedWarnMsg = 'Using a LeafyGreenUI Icon or Glyph component as a trigger will not render a Tooltip,' + ' as these components do not render their children.' + ' To use, please wrap your trigger element in another HTML tag.'; let warn: jest.SpyInstance; beforeEach(() => { warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { warn.mockClear(); warn.mockRestore(); }); test('LeafyGreen UI Glyph is passed to trigger', () => { render(}>TooltipContent); waitFor(() => { expect(warn).toHaveBeenCalledTimes(1); expect(warn).toHaveBeenCalledWith(expectedWarnMsg); }); }); test('LeafyGreen UI Icon is passed to trigger', async () => { render( }>TooltipContent, ); await waitFor(() => { expect(warn).toHaveBeenCalledTimes(1); expect(warn).toHaveBeenCalledWith(expectedWarnMsg); }); }); test('No warning for default props', () => { const defaultProps: TooltipProps = { children: 'Tooltip content', trigger: , }; render(); waitFor(() => { expect(warn).not.toHaveBeenCalled(); }); }); }); describe('when an interactive element is rendered inside of the Tooltip', () => { test('click events fire from inside of the tooltip', async () => { const clickHandler = jest.fn(); render( {buttonText}} > , ); await userEvent.click(screen.getByText(buttonText)); const tooltipButton = screen.getByText('Button inside of Tooltip'); await userEvent.click(tooltipButton); expect(clickHandler).toHaveBeenCalledTimes(1); }); }); describe('when the trigger has an event handler', () => { const renderTooltipWithTrigger = ( event: 'hover' | 'click', element: ReactElement, ) => { const result = render(
Tooltip Contents!
, ); const trigger = screen.getByText(buttonText); return { ...result, trigger }; }; test('onClick events should fire', async () => { const clickHandler = jest.fn(); const { trigger } = renderTooltipWithTrigger( 'click', , ); await userEvent.click(trigger); expect(clickHandler).toHaveBeenCalled(); }); test('onFocus events should fire', async () => { const focusHandler = jest.fn(); const { trigger } = renderTooltipWithTrigger( 'hover', , ); trigger.focus(); expect(focusHandler).toHaveBeenCalled(); }); test('onBlur events should fire', async () => { const blurHandler = jest.fn(); const { trigger } = renderTooltipWithTrigger( 'hover', , ); await userEvent.click(trigger); expect(trigger).toHaveFocus(); await userEvent.tab(); expect(blurHandler).toHaveBeenCalled(); }); test('onMouseEnter events should fire', async () => { const mouseEnterHandler = jest.fn(); const { trigger } = renderTooltipWithTrigger( 'hover', , ); await userEvent.hover(trigger); await waitFor(() => { expect(mouseEnterHandler).toHaveBeenCalled(); }); }); test('onMouseLeave events should fire', async () => { const mouseLeaveHandler = jest.fn(); const { trigger } = renderTooltipWithTrigger( 'hover', , ); await userEvent.hover(trigger); await userEvent.unhover(trigger); await waitFor(() => { expect(mouseLeaveHandler).toHaveBeenCalled(); }); }); test('"onClose" callback is triggered when the tooltip is closed internally', async () => { const onClose = jest.fn(); const { getByTestId, button } = renderTooltip({ triggerEvent: 'click', onClose, }); await userEvent.click(button); const tooltip = getByTestId(tooltipTestId); await act(async () => { waitForTimeout(transitionDuration.slowest); }); await waitFor(() => expect(tooltip).toBeVisible()); await userEvent.keyboard('{Escape}'); expect(onClose).toHaveBeenCalledTimes(1); await waitForElementToBeRemoved(tooltip); }); }); });