Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions packages/ui-client/src/providers/TooltipProvider.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(
<TooltipProvider>
<button type='button' title='Hello'>
anchor
</button>
</TooltipProvider>,
);

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(
<TooltipProvider>
<button type='button' data-tooltip='Hello'>
anchor
</button>
</TooltipProvider>,
);

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');
});
});
70 changes: 42 additions & 28 deletions packages/ui-client/src/providers/TooltipProvider.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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');
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const TooltipProvider = ({ children, ownerDocument = window.document }: TooltipProviderProps) => {
const lastAnchor = useRef<HTMLElement>(undefined);
const dismissedAnchor = useRef<HTMLElement>(undefined);
const hasHover = !useMediaQuery('(hover: none)');

const [tooltip, setTooltip] = useDebouncedState<ReactNode>(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(<TooltipComponent key={new Date().toISOString()} title={tooltip} anchor={anchor} />);
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(() => {
Expand Down Expand Up @@ -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);

Expand All @@ -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);
});

Expand All @@ -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}</>;
Expand Down
Loading