diff --git a/packages/ui-client/src/providers/TooltipProvider.spec.tsx b/packages/ui-client/src/providers/TooltipProvider.spec.tsx new file mode 100644 index 0000000000000..da477ca34f307 --- /dev/null +++ b/packages/ui-client/src/providers/TooltipProvider.spec.tsx @@ -0,0 +1,203 @@ +/* eslint-disable testing-library/prefer-user-event */ +import { act, fireEvent, render, screen } from '@testing-library/react'; + +import TooltipProvider from './TooltipProvider'; + +beforeEach(() => { + jest.useFakeTimers(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +const setup = () => { + render( + + + , + ); + + return { + anchor: screen.getByRole('button', { name: 'anchor' }), + }; +}; + +const waitForTooltipDebounce = () => { + act(() => { + jest.advanceTimersByTime(300); + }); +}; + +it('should show the tooltip on hover and stash the title attribute', () => { + const { anchor } = setup(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Hello'); + expect(anchor).toHaveAttribute('title', ''); + expect(anchor).toHaveAttribute('data-title', 'Hello'); +}); + +it('should restore the title attribute on unhover without depending on timers', () => { + const { anchor } = setup(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + fireEvent.mouseLeave(anchor); + + // the title must be restored synchronously on unmount, before any timer runs; otherwise a still-attached + // MutationObserver can blank it again, permanently suppressing the tooltip for this element + expect(screen.queryByRole('tooltip', { hidden: true })).not.toBeInTheDocument(); + expect(anchor).toHaveAttribute('title', 'Hello'); + expect(anchor).not.toHaveAttribute('data-title'); +}); + +it('should show the tooltip again after a full close and reopen cycle', () => { + const { anchor } = setup(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + fireEvent.mouseLeave(anchor); + act(() => { + jest.runOnlyPendingTimers(); + }); + expect(screen.queryByRole('tooltip', { hidden: true })).not.toBeInTheDocument(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Hello'); +}); + +it('should update the tooltip and re-stash the title when it changes while open', async () => { + const { anchor } = setup(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + + // the MutationObserver callback runs as a microtask, so the update must be flushed asynchronously + await act(async () => { + anchor.setAttribute('title', 'World'); + }); + + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('World'); + expect(anchor).toHaveAttribute('title', ''); + expect(anchor).toHaveAttribute('data-title', 'World'); +}); + +describe('click-dismiss while still hovering', () => { + it('should keep the title stashed after a click-dismiss while the cursor is still on the anchor', () => { + const { anchor } = setup(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Hello'); + + fireEvent.click(anchor); + + expect(screen.queryByRole('tooltip', { hidden: true })).not.toBeInTheDocument(); + expect(anchor).toHaveAttribute('title', ''); + expect(anchor).toHaveAttribute('data-title', 'Hello'); + }); + + it('should restore the title when the cursor leaves after a click-dismiss', () => { + const { anchor } = setup(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + fireEvent.click(anchor); + + fireEvent.mouseLeave(anchor); + + expect(anchor).toHaveAttribute('title', 'Hello'); + expect(anchor).not.toHaveAttribute('data-title'); + }); + + it('should show the tooltip again after a click-dismiss and mouseleave cycle', () => { + const { anchor } = setup(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + fireEvent.click(anchor); + fireEvent.mouseLeave(anchor); + act(() => { + jest.runOnlyPendingTimers(); + }); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Hello'); + }); +}); + +describe('with a `data-tooltip`-only anchor', () => { + const setupDataTooltip = () => { + render( + + + , + ); + + return { + anchor: screen.getByRole('button', { name: 'anchor' }), + }; + }; + + it('should show the tooltip without ever adding a title attribute', () => { + const { anchor } = setupDataTooltip(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Hello'); + expect(anchor).not.toHaveAttribute('title'); + expect(anchor).not.toHaveAttribute('data-title'); + + fireEvent.mouseLeave(anchor); + + // the anchor must not gain a native title on close; that would leak the tooltip text to the browser's tooltip + expect(screen.queryByRole('tooltip', { hidden: true })).not.toBeInTheDocument(); + expect(anchor).not.toHaveAttribute('title'); + expect(anchor).not.toHaveAttribute('data-title'); + }); + + it('should update the tooltip when `data-tooltip` changes while open', async () => { + const { anchor } = setupDataTooltip(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + + // the MutationObserver callback runs as a microtask, so the update must be flushed asynchronously + await act(async () => { + anchor.setAttribute('data-tooltip', 'World'); + }); + + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('World'); + expect(anchor).not.toHaveAttribute('title'); + }); + + it('should show the tooltip again after a click-dismiss and mouseleave cycle', () => { + const { anchor } = setupDataTooltip(); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + fireEvent.click(anchor); + fireEvent.mouseLeave(anchor); + act(() => { + jest.runOnlyPendingTimers(); + }); + + fireEvent.mouseOver(anchor); + waitForTooltipDebounce(); + + expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Hello'); + expect(anchor).not.toHaveAttribute('title'); + }); +}); diff --git a/packages/ui-client/src/providers/TooltipProvider.tsx b/packages/ui-client/src/providers/TooltipProvider.tsx index ab935bdb1bb0a..2aba6f8d9d9d0 100644 --- a/packages/ui-client/src/providers/TooltipProvider.tsx +++ b/packages/ui-client/src/providers/TooltipProvider.tsx @@ -1,7 +1,7 @@ import { useDebouncedState, useMediaQuery } from '@rocket.chat/fuselage-hooks'; import { TooltipContext } from '@rocket.chat/ui-contexts'; import type { ReactNode } from 'react'; -import { useEffect, useMemo, useRef, memo, useCallback, useState } from 'react'; +import { useEffect, useMemo, useRef, memo, useState } from 'react'; import { TooltipComponent } from '../components/TooltipComponent'; @@ -10,46 +10,63 @@ export type TooltipProviderProps = { ownerDocument?: Document; }; +const stashAnchorTitle = (anchor: HTMLElement, title: string): void => { + if (!anchor.hasAttribute('title')) { + return; + } + anchor.setAttribute('data-title', title); + anchor.setAttribute('title', ''); +}; + +const restoreAnchorTitle = (anchor: HTMLElement): void => { + if (!anchor.hasAttribute('data-title')) { + return; + } + if (!anchor.getAttribute('title')) { + anchor.setAttribute('title', anchor.getAttribute('data-title') ?? ''); + } + anchor.removeAttribute('data-title'); +}; + const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipProviderProps) => { const lastAnchor = useRef(undefined); + const dismissedAnchor = useRef(undefined); const hasHover = !useMediaQuery('(hover: none)'); const [tooltip, setTooltip] = useDebouncedState(null, 300); - const restoreTitle = useCallback((previousAnchor: HTMLElement | undefined): void => { - setTimeout(() => { - if (previousAnchor && !previousAnchor.getAttribute('title')) { - previousAnchor.setAttribute('title', previousAnchor.getAttribute('data-title') ?? ''); - previousAnchor.removeAttribute('data-title'); - } - }, 0); - }, []); - const contextValue = useMemo( () => ({ open: (tooltip: ReactNode, anchor: HTMLElement): void => { - const previousAnchor = lastAnchor.current; setTooltip(); lastAnchor.current = anchor; - if (previousAnchor) { - restoreTitle(previousAnchor); - } }, close: (): void => { - const previousAnchor = lastAnchor.current; setTooltip(null); setTooltip.flush(); lastAnchor.current = undefined; - if (previousAnchor) { - restoreTitle(previousAnchor); - } }, dismiss: (): void => { + const anchor = lastAnchor.current; setTooltip(null); setTooltip.flush(); + + if (anchor) { + dismissedAnchor.current = anchor; + const restoreOnLeave = (): void => { + restoreAnchorTitle(anchor); + if (dismissedAnchor.current === anchor) { + dismissedAnchor.current = undefined; + } + if (lastAnchor.current === anchor) { + lastAnchor.current = undefined; + } + }; + anchor.addEventListener('mouseleave', restoreOnLeave, { once: true }); + } }, }), - [setTooltip, restoreTitle], + [setTooltip], ); useEffect(() => { @@ -85,10 +102,7 @@ const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipP const [state, setState] = useState(title); useEffect(() => { const close = (): void => contextValue.close(); - // store the title in a data attribute - anchor.setAttribute('data-title', title); - // Removes the title attribute to prevent the browser's tooltip from showing - anchor.setAttribute('title', ''); + stashAnchorTitle(anchor, title); anchor.addEventListener('mouseleave', close); @@ -99,11 +113,7 @@ const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipP return; } - // store the title in a data attribute - anchor.setAttribute('data-title', title); - // Removes the title attribute to prevent the browser's tooltip from showing - anchor.setAttribute('title', ''); - + stashAnchorTitle(anchor, title); setState(title); }); @@ -114,7 +124,11 @@ const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipP return () => { anchor.removeEventListener('mouseleave', close); + // the observer must be disconnected before restoring the title, otherwise it would stash it again observer.disconnect(); + if (dismissedAnchor.current !== anchor) { + restoreAnchorTitle(anchor); + } }; }, []); return <>{state};