React show Material-UI Tooltip only for text that has ellipsis
Asked Answered
N

13

28

Looking for a way to have material-ui's tooltip expand the text in a table cell ONLY if the text is cut off with an ellipsis (overflowing).

Currently in my table I have a cell like this:

<TableCell className={classes.descriptionCell}>{row.description}</TableCell>

and my styling for descriptionCell is like this:

    descriptionCell: {
        whiteSpace: 'nowrap',
        maxWidth: '200px',
        overflow: 'hidden',
        textOverflow: 'ellipsis'
    }

This makes the text behave the way I would like it to in this table, but I want to be able to hover and view the rest of it in a tooltip, preferably Material-UI's built in tooltip component.

I know there is a package that exists here https://www.npmjs.com/package/react-ellipsis-with-tooltip which should do this, BUT it uses bootstrap tooltip, not material UI.

Nobility answered 13/6, 2019 at 21:12 Comment(1)
Here’s a starting point: #7738617Multifold
F
22

To go off of @benjamin.keen answer. Here is a standalone functional component which is just an extension of his answer using hooks to perform the comparison functions.

import React, { useRef, useEffect, useState } from 'react';
import Tooltip from '@material-ui/core/Tooltip';
const OverflowTip = props => {
  // Create Ref
  const textElementRef = useRef();

  const compareSize = () => {
    const compare =
      textElementRef.current.scrollWidth > textElementRef.current.clientWidth;
    console.log('compare: ', compare);
    setHover(compare);
  };

  // compare once and add resize listener on "componentDidMount"
  useEffect(() => {
    compareSize();
    window.addEventListener('resize', compareSize);
  }, []);

  // remove resize listener again on "componentWillUnmount"
  useEffect(() => () => {
    window.removeEventListener('resize', compareSize);
  }, []);

  // Define state and function to update the value
  const [hoverStatus, setHover] = useState(false);

  return (
    <Tooltip
      title={props.value}
      interactive
      disableHoverListener={!hoverStatus}
      style={{fontSize: '2em'}}
    >
      <div
        ref={textElementRef}
        style={{
          whiteSpace: 'nowrap',
          overflow: 'hidden',
          textOverflow: 'ellipsis'
        }}
      >
        {props.someLongText}
      </div>
    </Tooltip>
  );
};

export default OverflowTip;
Fourinhand answered 16/12, 2019 at 21:25 Comment(6)
You should combine useEffect hooks into single one by returning () => { window.removeEventListener('resize', compareSize); } from the first one.Hairbrush
It probably makes more sense to check the size on the element's mouseover because the content can overflow even if the window hasn't changed its size.Abshire
I have a weird issue with this solution, clientWidth returned is wrong and always the same as scrollWidth, unless I put some static content inside the div. If I include at least some static character, it works correctly. What could be the issue? It is driving me crazyCollimator
I figure the issue above. It turns out that since ellipsis is included in clientWidth, for some content clientWidth == scrollWidth even with ellipsis due to rounding. This issue is described here: github.com/w3c/csswg-drafts/issues/4123Collimator
@Collimator you can get a precise value by using getBoundingClientRect.width. Check out this solution: https://mcmap.net/q/37886/-react-show-material-ui-tooltip-only-for-text-that-has-ellipsisShit
@LeandroOrtiz yeah, that's my response :)Collimator
C
16

based on benjamin.keen answer, this is the functional version of his code:

import React, { useRef, useState, useEffect } from 'react';
import Tooltip from '@material-ui/core/Tooltip';

const OverflowTip = ({ children }) => {
  const [isOverflowed, setIsOverflow] = useState(false);
  const textElementRef = useRef();
  useEffect(() => {
    setIsOverflow(textElementRef.current.scrollWidth > textElementRef.current.clientWidth);
  }, []);
  return (
    <Tooltip title={children} disableHoverListener={!isOverflowed}>
      <div
        ref={textElementRef}
        style={{
          whiteSpace: 'nowrap',
          overflow: 'hidden',
          textOverflow: 'ellipsis',
        }}
      >
        {children}
      </div>
    </Tooltip>
  );
};
Caryloncaryn answered 20/4, 2020 at 8:45 Comment(0)
A
7

I ran into this same problem today and @vijay-menon's answer was very helpful. Here's a simple standalone component for the same thing:

import React, { Component } from 'react';
import Tooltip from '@material-ui/core/Tooltip';

class OverflowTip extends Component {
    constructor(props) {
        super(props);
        this.state = {
            overflowed: false
        };
        this.textElement = React.createRef();
    }

    componentDidMount () {
        this.setState({
            isOverflowed: this.textElement.current.scrollWidth > this.textElement.current.clientWidth
        });
    }

    render () {
        const { isOverflowed } = this.state;
        return (
            <Tooltip
                title={this.props.children}
                disableHoverListener={!isOverflowed}>
                <div
                    ref={this.textElement}
                    style={{
                        whiteSpace: 'nowrap',
                        overflow: 'hidden',
                        textOverflow: 'ellipsis'
                    }}>
                    {this.props.children}
                </div>
            </Tooltip>
        );
    }
}

Example usage:

<OverflowTip>
      some long text here that may get truncated based on space
</OverflowTip>

The one nuisance is that if the space for the element dynamically changes in the page (e.g. page resize or dynamic DOM change) it won't acknowledge the new space and recompute whether it's overflowed.

Other tooltip libraries like Tippy have a method that's fired when trying to open the tooltip. That's a perfect place to do the overflow check because it'll always work, regardless if the DOM width had changed for the text element. Unfortunately it's fussier to do that with the API provided by Material UI.

Autochthon answered 22/9, 2019 at 17:48 Comment(0)
T
5

Please find the codesandbox below - https://codesandbox.io/s/material-demo-p2omr

I am using ref here to get the TableCell DOM Node and then comparing the scrollWidth and clientWidth to determine if Tooltip has to be displayed.(This is based on answer here)

I have added "rowref" (property that has the ref) and "open" (disable/enable tooltip) as new properties to the rows. I don't know where your data is coming from, but I am assuming you can add these properties to the row.

One more thing to note, I am only setting "disableHoverListener" prop to disable tooltip . There are other props - "disableFocusListener" & "disableTouchListener" , If you want to use those. More info here

Hope this works out for you. Let me know if you have any doubts in the code.

Thies answered 13/6, 2019 at 22:19 Comment(4)
This will do the tooltip unconditionally. The question is asking how to only have the tooltip when the text overflows.Multifold
@RyanCogswell Yes, exactly. Need it only when text overflows not all the time. I'll make that more clear in the question if it wasn't already.Nobility
I understood the requirement incorrectly. I have removed my original answer and added a new answer. Let me know if this works.Thies
@dave99collins, please let me know if this worked for you .Thies
A
4

based on @Dheeraj answer - this is the very close to his component but in type script version and more makes sense props names:

import React, { useRef, useEffect, useState } from 'react';
import Tooltip from '@material-ui/core/Tooltip';

interface Props {
  tooltip: string;
  text: string;
}

const OverflowTooltip = (props: Props) => {

  const textElementRef = useRef<HTMLInputElement | null>(null);

  const compareSize = () => {
    const compare =
      textElementRef.current.scrollWidth > textElementRef.current.clientWidth;
    setHover(compare);
  };

  useEffect(() => {
    compareSize();
    window.addEventListener('resize', compareSize);
  }, []);

  useEffect(() => () => {
    window.removeEventListener('resize', compareSize);
  }, []);

  const [hoverStatus, setHover] = useState(false);

  return (
    <Tooltip
      title={props.tooltip}
      interactive
      disableHoverListener={!hoverStatus}
    >
      <div
        ref={textElementRef}
        style={{
          whiteSpace: 'nowrap',
          overflow: 'hidden',
          textOverflow: 'ellipsis',
        }}
      >
        {props.text}
      </div>
    </Tooltip>
  );
};

export default OverflowTooltip;

and we use it like this:

<OverflowTooltip 
    tooltip={'tooltip message here'}
    text={'very long text here'}
/>
Alina answered 2/6, 2020 at 11:16 Comment(0)
S
4

If someone needs a TypScript version:

import { Tooltip, Typography, TypographyProps } from "@mui/material";
import { FC, ReactChild, useEffect, useRef, useState } from "react";

export interface OverflowTypograpyProps extends TypographyProps {
  children: ReactChild;
}

export const OverflowTypograpy: FC<OverflowTypograpyProps> = ({
  children,
  ...props
}) => {
  const ref = useRef<HTMLSpanElement>(null);
  const [tooltipEnabled, setTooltipEnabled] = useState(false);

  useEffect(() => {
    const compareSize = () => {
      if (ref.current) {
        const compare = ref.current.scrollWidth > ref.current.clientWidth;

        setTooltipEnabled(compare);
      }
    };
    compareSize();
    window.addEventListener("resize", compareSize);
    return () => window.removeEventListener("resize", compareSize);
  }, []);

  return (
    <Tooltip title={children} disableHoverListener={!tooltipEnabled}>
      <Typography
        ref={ref}
        noWrap
        overflow="hidden"
        textOverflow="ellipsis"
        {...props}
      >
        {children}
      </Typography>
    </Tooltip>
  );
};
Spavined answered 16/3, 2022 at 13:36 Comment(0)
Z
4

I don't think you need to get into any side effect hooks. The top post suggests putting an event listener on the window which fires on every mouse move event. We can just define some callbacks and pass them to onMouseEnter and onMouseLeave

import React, { useState, MouseEvent } from "react";
import Tooltip, { TooltipProps } from "@mui/material/Tooltip";

export const OverflowTooltip = ({ children, ...props }: TooltipProps) => {
  const [tooltipEnabled, setTooltipEnabled] = useState(false);

  const handleShouldShow = ({ currentTarget }: MouseEvent<Element>) => {
    if (currentTarget.scrollWidth > currentTarget.clientWidth) {
      setTooltipEnabled(true);
    }
  };

  const hideTooltip = () => setTooltipEnabled(false);

  return (
    <Tooltip
      onMouseEnter={handleShouldShow}
      onMouseLeave={hideTooltip}
      disableHoverListener={!tooltipEnabled}
      {...props}
    >
      <div
        style={{
          whiteSpace: 'nowrap',
          overflow: 'hidden',
          textOverflow: 'ellipsis',
        }}
      >
        {children}
      </div>
      {children}
    </Tooltip>
  );
};
Zandra answered 25/3, 2022 at 17:14 Comment(1)
hide tooltip onMouseLeave was not the scenario I was expecting, for disabling HoverListener, hence I had to modify the handleShouldShow as follows const handleShouldShow = ({ currentTarget }: MouseEvent<Element>) => { if (currentTarget.scrollWidth > currentTarget.clientWidth) { setTooltipEnabled(true); } else setTooltipEnabled(false) };Quark
I
3

Taking inspiration from all the above answers, I have got it to working even when it is resizing. The trick is to just make it a controlled tooltip.

You can check the implementation here as well https://codesandbox.io/s/controlled-tooltip-for-text-ellipsis-mkj1vp (Please resize the output window to see the ellipsis effect)

import { useState, MouseEvent } from "react";
import { Tooltip, Typography } from "@mui/material";

const LONGLABEL = "abcdefgh ijklmn opqrst uvwzyz";

export const OverflowTooltipExample = () => {
  const [tooltipEnabled, setTooltipEnabled] = useState(false);

  const handleShouldShow = ({ currentTarget }: MouseEvent<Element>) => {
    if (currentTarget.scrollWidth > currentTarget.clientWidth) {
      setTooltipEnabled(true);
    }
  };

  return (
    <Tooltip
      title={LONGLABEL}
      open={tooltipEnabled}
      onClose={() => setTooltipEnabled(false)}
    >
      <Typography onMouseEnter={handleShouldShow} noWrap>
        {LONGLABEL}
      </Typography>
    </Tooltip>
  );
};
Introspect answered 13/4, 2023 at 9:47 Comment(0)
C
2

The method of defining whether the text is overflowed has a flaw in the accepted answer. Since scrollWidth and clientWidth return rounded integer values, when the difference between them is small, then we will get equal values and tooltip won't work. The problem is that ellipsis is also counted as clientWidth, so when we have an overflow of just one or tho characters, we will see ellipsis, but scrollWidth and clientWidth would be equal. Below is the solution which worked for me to determine scrollWidth and clientWidth with fractional accuracy and fixed this issue:

import React, { useRef, useState, useEffect } from 'react';
import { Tooltip } from '@material-ui/core';

const OverflowTooltip = ({ children }) => {
    const textElementRef = useRef();
    
    const checkOverflow = () => {
        // Using getBoundingClientRect, instead of scrollWidth and clientWidth, to get width with fractional accuracy
        const clientWidth = textElementRef.current.getBoundingClientRect().width

        textElementRef.current.style.overflow = 'visible';
        const contentWidth = textElementRef.current.getBoundingClientRect().width
        textElementRef.current.style.overflow = 'hidden';

        setIsOverflow(contentWidth > clientWidth);
    }
    
    useEffect(() => {
        checkOverflow();
        window.addEventListener('resize', checkOverflow)
        return () => {
            window.removeEventListener('resize', checkOverflow)
        }
    }, []);
    
    const [isOverflowed, setIsOverflow] = useState(false);
    
  return (  
    <Tooltip title={children} disableHoverListener={!isOverflowed}>       
      <span ref={textElementRef}
            style={{
                whiteSpace: 'nowrap',
                overflow: 'hidden',
                textOverflow: 'ellipsis',
            }}
        >
            {children}
      </span>
    </Tooltip>
  );
};
export default OverflowTooltip
Collimator answered 19/9, 2021 at 17:35 Comment(0)
G
0
import React from 'react';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';

interface TooltipOverflowProps {
    text: string;
}

const BoxOverflowTooltip = React.forwardRef(({ text }: { text: string }, ref) => <Box ref={ref} style={{ overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>{text}</Box>);

export const OverflowTooltip = (props: TooltipOverflowProps) => {
    const ref = React.useRef<HTMLDivElement>(null);
    const [isOverflowed, setIsOverflowed] = React.useState(false);

    React.useEffect(() => {
        const description = ref.current;
        !!description && description.scrollWidth > description.clientWidth && setIsOverflowed(true);
    }, []);

    const clonedChildWithProps = React.cloneElement( <BoxOverflowTooltip ref={ref} text={props.text}/>, { ref });

    return (
        <React.Fragment>
            <Tooltip arrow title={props.text} disableHoverListener={!isOverflowed}>
                <Typography component="div">
                    {clonedChildWithProps}
                </Typography>
            </Tooltip>
        </React.Fragment>
    );
};
Gainer answered 13/6, 2019 at 21:12 Comment(0)
M
0

If you want to show the tooltip only if the content overflows this will work.

The useEffect() is needed because the ref.current is null initially, but when the component mounts it gets set and you can grab the html element based on it.

interface MyInterface {
    content: Content;
}

export const MyComponent: React.FC<MyInterface> = ({ content }) => {
const ref = useRef(null);

const [showTooltip, setShowTooltip] = useState(false);

useEffect(() => {
    if (!ref.current) return;

    const div = ref.current as HTMLDivElement;
    const isOverflow = div.offsetWidth < div.scrollWidth;
    setShowTooltip(isOverflow);
}, []);

const renderContent = () => (
    <div ref={ref}>
        content
    </div>
);

return (
    <>
        {ref.current && showTooltip ? (
            <Tooltip title={content.value}>
                {renderContent()}
            </Tooltip>
        ) : (
            renderContent()
        )}
    </>
);

};

Moa answered 25/1, 2022 at 16:3 Comment(0)
B
0

My Solution : Note==> scrollWidth and clientWidth would be zero for span, we should use div

import React, { useRef, useState, useEffect } from 'react'
import {
    Tooltip,
    styled
} from '@mui/material'

const StyledDiv = styled('div')({
    whiteSpace: 'nowrap',
    overflow: 'hidden',
    textOverflow: 'ellipsis',
})

const OverflowTooltip = ({ children }) => {
    const textElementRef = useRef()

    const checkOverflow = () => {
        setIsOverflow(textElementRef.current.scrollWidth > textElementRef.current.clientWidth)
    }

    useEffect(() => {
        checkOverflow()
        window.addEventListener('resize', checkOverflow)
        return () => {
            window.removeEventListener('resize', checkOverflow)
        }
    }, [])

    const [isOverflowed, setIsOverflow] = useState(false)

return (  

        <StyledDiv ref={textElementRef} >
            <Tooltip title={children} disableHoverListener={!isOverflowed}>       
                {children}
            </Tooltip>
        </StyledDiv>
)
}
export default OverflowTooltip
      
Bailable answered 3/10, 2023 at 11:19 Comment(0)
G
0

If you are using the method with the resize event listener, to limit the amount of calculations called you can use throttle from the lodash library. Refer to https://iamakulov.com/notes/resize-scroll/#throttle.

import { throttle } from 'lodash';

useEffect(() => {
    checkOverflow();
    window.addEventListener(
        'resize',
        throttle(() => {
            checkOverflow();
        }, 200)
    );
}, []);
Girandole answered 4/4, 2024 at 22:7 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.