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}>;