Skip to content

Commit

Permalink
feat: improve overflow detection using `getClientRects()
Browse files Browse the repository at this point in the history
  • Loading branch information
cheton committed Nov 15, 2023
1 parent 02190d4 commit 28da282
Show file tree
Hide file tree
Showing 3 changed files with 119 additions and 29 deletions.
62 changes: 38 additions & 24 deletions packages/react/src/tooltip/OverflowTooltip.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,6 @@ import { Truncate } from '../truncate';
import { useTruncateStyle } from '../truncate/styles';
import Tooltip from './Tooltip';

const hasEllipsis = (el) => {
const isNoWrap = el.style.whiteSpace === 'nowrap' || window.getComputedStyle(el).whiteSpace === 'nowrap';
if (isNoWrap) {
let scrollWidth = el.scrollWidth;
const oldWidth = el.style.width;
el.style.width = "max-content"; // set width to max-content to get the actual width of the element
const [clientRect] = el.getClientRects();
if (clientRect?.width > scrollWidth) {
scrollWidth = clientRect?.width;
}
el.style.width = oldWidth;
return scrollWidth > el.clientWidth;
}

return el.scrollHeight > el.clientHeight;
};

const OverflowTooltip = forwardRef((
{
children,
Expand All @@ -29,23 +12,54 @@ const OverflowTooltip = forwardRef((
ref,
) => {
const contentRef = useRef();
const [isOverflown, setIsOverflown] = useState();
const [isOverflow, setIsOverflow] = useState();
const truncateStyle = useTruncateStyle();

const detectOverflow = useCallback((el, sizeProperty) => {
if (sizeProperty !== 'width' && sizeProperty !== 'height') {
console.error(`Invalid size property: ${sizeProperty}. Use 'width' or 'height'.`);
return false;
}

const originalSize = el.style[sizeProperty];
el.style[sizeProperty] = 'max-content';
const newSize = el.getClientRects()?.[0]?.[sizeProperty];
el.style[sizeProperty] = originalSize;

if (sizeProperty === 'width') {
return originalSize < newSize || el.scrollWidth > el.clientWidth;
}

if (sizeProperty === 'height') {
return originalSize < newSize || el.scrollHeight > el.clientHeight;
}

return false;
}, []);

const eventTargetFn = useCallback(() => {
return contentRef.current;
}, []);

const onMouseEnter = useCallback((event) => {
const el = event.currentTarget;
setIsOverflown(hasEllipsis(el));
}, []);
const isWidthOverflow = detectOverflow(el, 'width');
const isHeightOverflow = detectOverflow(el, 'height');
const isOverflowDetected = isWidthOverflow || isHeightOverflow;
setIsOverflow(isOverflowDetected);
}, [detectOverflow]);

const onMouseLeave = useCallback((event) => {
setIsOverflown(false);
setIsOverflow(false);
}, []);

useEventListener(() => contentRef.current, 'mouseenter', onMouseEnter);
useEventListener(() => contentRef.current, 'mouseleave', onMouseLeave);
useEventListener(eventTargetFn, 'mouseenter', onMouseEnter);
useEventListener(eventTargetFn, 'mouseleave', onMouseLeave);

return (
<Tooltip
ref={ref}
disabled={!isOverflown}
disabled={!isOverflow}
{...rest}
>
{(typeof children === 'function') ? (
Expand Down
76 changes: 76 additions & 0 deletions packages/react/src/tooltip/__tests__/OverflowTooltip.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '@tonic-ui/react/test-utils/render';
import { Box, OverflowTooltip } from '@tonic-ui/react/src';
import React from 'react';

describe('OverflowTooltip', () => {
it('should display an overflow tooltip tooltip if the clientWidth is less than the scrollWidth', async () => {
const user = userEvent.setup();
const textContent = 'This text string will be truncated when exceeding its container width. To see this in action, try resizing your browser viewport. If the text overflows, a tooltip will appear, displaying the full content.';
const tooltipLabel = 'tooltip label';

// Define the scrollWidth, offsetWidth, and clientWidth of the textContent
const scrollWidth = 1193;
const offsetWidth = 900;
const clientWidth = 900;
const maxWidth = scrollWidth;

render(
<Box maxWidth={maxWidth}>
<OverflowTooltip label={tooltipLabel}>
{textContent}
</OverflowTooltip>
</Box>
);

const text = screen.getByText(textContent);

// Mock scrollWidth, offsetWidth, and clientWidth to simulate overflow
Object.defineProperty(text, 'scrollWidth', { configurable: true, value: scrollWidth });
Object.defineProperty(text, 'offsetWidth', { configurable: true, value: offsetWidth });
Object.defineProperty(text, 'clientWidth', { configurable: true, value: clientWidth });

await user.hover(text);

await waitFor(() => {
expect(screen.queryByText(tooltipLabel)).toBeInTheDocument();
});
});

it('should display an overflow tooltip when the `clientRect` varies while setting the width to `max-content`', async () => {
const user = userEvent.setup();
const textContent = 'This is a string to test overflow tooltip';
const tooltipLabel = 'tooltip label';

// Define the clientRects of the textContent
const contentWidth = 120;
const contentHeight = 20;
const maxContentWidth = 120.84;
const maxContentHeight = 20;

render(
<Box width={contentWidth}>
<OverflowTooltip label={tooltipLabel}>
{textContent}
</OverflowTooltip>
</Box>
);

const text = screen.getByText(textContent);

// Mock the getClientRects() function to simulate overflow
text.getClientRects = jest.fn();
text.getClientRects
.mockReturnValueOnce([{ x: 0, y: 0, top: 0, bottom: contentHeight, left: 0, right: contentWidth, width: contentWidth, height: contentHeight }])
.mockReturnValueOnce([{ x: 0, y: 0, top: 0, bottom: contentHeight, left: 0, right: maxContentWidth, width: maxContentWidth, height: contentHeight }])
.mockReturnValueOnce([{ x: 0, y: 0, top: 0, bottom: contentHeight, left: 0, right: contentWidth, width: contentWidth, height: contentHeight }])
.mockReturnValueOnce([{ x: 0, y: 0, top: 0, bottom: maxContentHeight, left: 0, right: contentWidth, width: contentWidth, height: maxContentHeight }]);

await user.hover(text);

await waitFor(() => {
expect(screen.queryByText(tooltipLabel)).toBeInTheDocument();
});
});
});
10 changes: 5 additions & 5 deletions packages/react/src/tooltip/__tests__/Tooltip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ describe('Tooltip', () => {
await testA11y(container);
});

it('should not show on mouseover if `disabled` is set to `false`', async () => {
it('should display a tooltip if `disabled` is set to `false`', async () => {
const user = userEvent.setup();

render(<TestComponent disabled={false} />);
Expand All @@ -45,7 +45,7 @@ describe('Tooltip', () => {
expect(await screen.findByRole('tooltip')).toBeInTheDocument();
});

it('should not show on mouseover if `disabled` is set to `true`', async () => {
it('should not display a tooltip if `disabled` is set to `true`', async () => {
const user = userEvent.setup();

render(<TestComponent disabled={true} />);
Expand All @@ -57,7 +57,7 @@ describe('Tooltip', () => {
});
});

it('should display on mouseover and close when clicked if `closeOnClick` is set to `true`', async () => {
it('should display a tooltip and close when clicked if `closeOnClick` is set to `true`', async () => {
const user = userEvent.setup();

render(<TestComponent closeOnClick={true} />);
Expand All @@ -74,7 +74,7 @@ describe('Tooltip', () => {
});
});

it('should display on mouseover and close when `Escape` key is pressed if `closeOnEsc` is set to `true`', async () => {
it('should display a tooltip and close when `Escape` key is pressed if `closeOnEsc` is set to `true`', async () => {
const user = userEvent.setup();

render(<TestComponent closeOnEsc={true} />);
Expand All @@ -91,7 +91,7 @@ describe('Tooltip', () => {
});
});

it('should display on mouseover and close when pointer down if `closeOnPointerDown` is set to `true`', async () => {
it('should display a tooltip and close when pointer down if `closeOnPointerDown` is set to `true`', async () => {
const user = userEvent.setup();

render(<TestComponent closeOnPointerDown={true} />);
Expand Down

0 comments on commit 28da282

Please sign in to comment.