Global react tooltip for clipped text with ellipsis
Every frontend developer faces the issue of not having enough space to show all the text in the container.
There is a common CSS solution:
.ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
Looks better, isn’t it? But what is the full text? In many cases it’s important for the user to know the full text. Ok, we can implement a custom component, pass a text as children and track a hover event to show tooltip.
<EllipsisText>
elit duis ut duis Lorem excepteur exercitation esse velit culpa sit
</EllipsisText>
Now we have a tooltip, but it is shown even when the text is not clipped. The ellipsis appears when offsetWidth of the element is less then scrollWidth.
Great! We have a component which can render a text with ellipsis and show a tooltip.
const TodoItem = ({title}) => (
<List.Item>
<Typography.Title level={5}>
<EllipsisText>
{title}
</EllipsisText>
</Typography.Title>
</Liet.Item>
);
Every place of the code should be wrapped with component. This component wrapper is extra React node and extra DOM element.
Simple component which renders a list of entity properties
const TodoItem = ({date, description, state, title}) => (
<Description>
<Property title='title'>
<TodoStateIcon state={state} />
{title}
</Property>
<Property title='description'>
{description}
</Property>
<Property title='Date'>
{date.format('LL LTS')}
</Property>
<Property title='state'>
{formatState(state)}
</Property>
</Description>
);
becomes a mess of multiple wrappers
const TodoItem = ({date, description, state, title}) => (
<Description>
<Property title='title'>
<EllipsisText>
<TodoStateIcon state={state} />
{title}
</EllipsisText>
</Property>
<Property title='description'>
<EllipsisText>
{description}
</EllipsisText>
</Property>
<Property title='Date'>
<EllipsisText>
{date.format('LL LTS')}
</EllipsisText>
</Property>
<Property title='state'>
<EllipsisText>
{formatState(state)}
</EllipsisText>
</Property>
</Description>
);
Global tooltip for text with ellipsis
The suggestion is to use a component which tracks all mouse-hovers on elements with ellipsis and shows a tooltip if the text is clipped. In the we can just add CSS styles with ellipsis for property values without any changes in initial component code.
EllipsisTooltips component
Add a listener to document’s mouseover event to track all child-hover events.
React.useEffect(() => {
const mouseOverHandler = (e) => setHoveredElement(e.target);
document.addEventListener('mouseover', mouseOverHandler);
return () => {
document.removeEventListener('mouseover', mouseOverHandler);
}
}, [setHoveredElement]);
Hover event can be raised on child of element with ellipsis, so we need to check target and all its ancestors for being clipped text.
function findBaseTooltipElement(element) {
const elementStyle = getComputedStyle(element);
if (isStyleWithEllipsis(elementStyle)) {
return isOverflowX(element) ? element : null;
}
if (isStyleWithClamp(elementStyle)) {
return isOverflowY(element) ? element : null;
}
return element.parentElement ? findBaseTooltipElement(element.parentElement) : null;
}
The component tracks text with ellipsis and text with limited number of lines by using the CSS line-clamp property.
const isStyleWithEllipsis = (style) => style.overflowX === 'hidden' &&
style.textOverflow === 'ellipsis' &&
style.whiteSpace === 'nowrap';
const isStyleWithClamp = (style) => style['-webkit-line-clamp'] > 0;
const isOverflowX = (element) => element.offsetWidth < element.scrollWidth;
const isOverflowY = (element) => element.offsetHeight < element.scrollHeight;
setHoveredElement from mouseover listener checks that new element is not a child of already hovered element with a shown tooltip
const setHoveredElement = React.useCallback((hoveredElement) => {
if (tooltipBaseElement.current && tooltipBaseElement.current !== hoveredElement && !tooltipBaseElement.current.contains(hoveredElement)) {
defineTooltipBaseElement.cancel();
tooltipBaseElement.current = null;
onShowTooltip?.(null);
}
defineTooltipBaseElement(hoveredElement);
}, [onShowTooltip, defineTooltipBaseElement]);
and calls defineTooltipBaseElement function which tries to find should the hovered element have a tooltip. The function is debounced to avoid tooltip flickering and delay tooltip show.
const defineTooltipBaseElement = React.useCallback(debounce((element) => {
const baseElement = findBaseTooltipElement(element);
tooltipBaseElement.current = baseElement;
onShowTooltip?.(baseElement);
}, debounceTimeMilliseconds), []);
The full code of component
import React from 'react';
import { debounce } from 'lodash';
function findBaseTooltipElement(element) {
const elementStyle = getComputedStyle(element);
if (isStyleWithEllipsis(elementStyle)) {
return isOverflowX(element) ? element : null;
}
if (isStyleWithClamp(elementStyle)) {
return isOverflowY(element) ? element : null;
}
return element.parentElement ? findBaseTooltipElement(element.parentElement) : null;
}
const isStyleWithEllipsis = (style) => style.overflowX === 'hidden' &&
style.textOverflow === 'ellipsis' &&
style.whiteSpace === 'nowrap';
const isStyleWithClamp = (style) => style['-webkit-line-clamp'] > 0;
const isOverflowX = (element) => element.offsetWidth < element.scrollWidth;
const isOverflowY = (element) => element.offsetHeight < element.scrollHeight;
export const EllipsisTooltips = React.memo(({
debounceTimeMilliseconds = 300,
onShowTooltip
}) => {
const tooltipBaseElement = React.useRef(null);
const defineTooltipBaseElement = React.useCallback(debounce((element) => {
const baseElement = findBaseTooltipElement(element);
tooltipBaseElement.current = baseElement;
onShowTooltip?.(baseElement);
}, debounceTimeMilliseconds), []);
const setHoveredElement = React.useCallback((hoveredElement) => {
if (tooltipBaseElement.current && tooltipBaseElement.current !== hoveredElement && !tooltipBaseElement.current.contains(hoveredElement)) {
defineTooltipBaseElement.cancel();
tooltipBaseElement.current = null;
onShowTooltip?.(null);
}
defineTooltipBaseElement(hoveredElement);
}, [onShowTooltip, defineTooltipBaseElement]);
React.useEffect(() => {
const mouseOverHandler = (e) => setHoveredElement(e.target);
document.addEventListener('mouseover', mouseOverHandler);
return () => {
document.removeEventListener('mouseover', mouseOverHandler);
}
}, [setHoveredElement]);
return null;
});
Developer should just define an onShowTooltip callback in props to show a tooltip. Every DOM node with clipped text with ellipsis will have a tooltip on hover.
Demo shows complex content in first block with ellipsis, 10 random filled blocks with line-clamp and 10 random filled blocks with ellipsis.
Github repository with example is here.
Top comments (0)