DEV Community

Mikhail Petrov
Mikhail Petrov

Posted on • Originally published at Medium on

Global react tooltip for clipped text with ellipsis

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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), []);
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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.

Codesandbox playground

Top comments (0)