20

I am trying to detect if the text is truncated using JS. The solution mentioned here works great except for an edge case below. As you will notice, the first block on mouse hover will return false if though the text is visually truncated.

function isEllipsisActive(e) {
  return (e.offsetWidth < e.scrollWidth);
}

function onMouseHover(e) {
  console.log(`is truncated: ${isEllipsisActive(e)}`);
}
div.red {
  margin-bottom: 1em;
  background: red;
  color: #fff;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  width: 300px;
  cursor: pointer;
}
<h6>Hover mouse and watch for console messages.</h6>

<!-- should return true -->
<div class="red" onmouseover="onMouseHover(this)">
  <a>Analytics reports comes through garbled. Plsss</a>
</div>

<!-- should return true -->
<div class="red" onmouseover="onMouseHover(this)">
  <a>Analytics reports comes through garbled. Plsssssss</a>
</div>

<!-- should return false -->
<div class="red" onmouseover="onMouseHover(this)">
  <a>Normal text</a>
</div>

The solution I am after is for the function to return true whenever the text is truncated by css.

8
  • first block hovered returns true!, are u testing it in specific browser? Commented Nov 5, 2020 at 0:09
  • @Boudyhesham It does not on chrome. Which one did you run on? Commented Nov 5, 2020 at 0:13
  • chrome Version 86.0.4240.111 Commented Nov 5, 2020 at 0:14
  • I am using 86.0.4240.111 as well, double and tripled checked, first div returns false for me. Commented Nov 5, 2020 at 0:17
  • can confirm not working on chrome, but works as it should on firefox 83.0b5 Commented Nov 5, 2020 at 1:05

4 Answers 4

9

Kaiido hit the nail on the head by mentioning that the problem is that offsetWidth and scrollWidth reflect rounded values, while the ellipsis is shown based on floating-point values. But he was unable to find a suitable cross-browser solution to the problem.

However, combining that knowledge with a modified version of see sharper's approach works perfectly in my tests, and should be reliable and cross-browser.

function isEllipsisActive(e) {
    const temp = e.cloneNode(true);

    temp.style.position = "fixed";
    temp.style.overflow = "visible";
    temp.style.whiteSpace = "nowrap";
    temp.style.visibility = "hidden";

    e.parentElement.appendChild(temp);

    try {
        const fullWidth = temp.getBoundingClientRect().width;
        const displayWidth = e.getBoundingClientRect().width;

        return fullWidth > displayWidth;
    } finally {
        temp.remove();
    }
}

function isEllipsisActive(e) {
    const temp = e.cloneNode(true);

    temp.style.position = "fixed";
    temp.style.overflow = "visible";
    temp.style.whiteSpace = "nowrap";
    temp.style.visibility = "hidden";

    e.parentElement.appendChild(temp);

    try {
        const fullWidth = temp.getBoundingClientRect().width;
        const displayWidth = e.getBoundingClientRect().width;

        return {
            offsetWidth: e.offsetWidth,
            scrollWidth: e.scrollWidth,
            fullWidth,
            displayWidth,
            truncated: fullWidth > displayWidth
        };
    } finally {
        temp.remove();
    }
}

function showSize(element, props) {
    const offset = element.nextElementSibling;
    const scroll = offset.nextElementSibling;
    const display = scroll.nextElementSibling;
    const full = display.nextElementSibling;
    const truncated = full.nextElementSibling;
    
    offset.textContent = props.offsetWidth;
    scroll.textContent = props.scrollWidth;
    display.textContent = props.displayWidth;
    
    const fixed = props.fullWidth.toFixed(3);
    full.innerHTML = fixed.replace(
        /\.?0+$/,
        "<span class='invisible'>$&</span>"
    );

    truncated.textContent = props.truncated ? "✔" : undefined;
}

function showAllSizes() {
    const query = ".container > .row:nth-child(n + 2) > *:first-child";
    for (const element of document.querySelectorAll(query)) {
        showSize(element, isEllipsisActive(element));
    }
}

document.addEventListener("readystatechange", () => {
    if (document.readyState !== "complete") {
        return;
    }

    const width = document.getElementById("width");
    width.addEventListener("change", () => {
        document.querySelector(".container").style.gridTemplateColumns =
            `${width.value}px repeat(5, auto)`;

        showAllSizes();
    });

    showAllSizes();
});
* {
    font-family: 'Roboto', sans-serif;
    font-size: 14px;
}

.container {
    display: inline-grid;
    grid-template-columns: 295px repeat(5, auto);
    gap: 8px;
    padding: 8px;
    border: 1px solid gray;
}

.container > .row {
    display: contents;
}

.container > .row > * {
    display: block;
    border-width: 1px;
    border-style: solid;
}

.container > .row:first-child > * {
    font-weight: bold;
    padding: 3px;
    text-align: center;
    border-color: gray;
    background-color: silver;
}

.container > .row:nth-child(n + 2) > *:first-child {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    border: 1px solid steelblue;
    background-color: lightsteelblue;
}

.container
> .row:nth-child(n + 2)
> *:nth-child(n + 2):not(:last-child) {
    border-color: khaki;
    background-color: lemonchiffon;
    text-align: right;
}

.container
> .row:nth-child(n + 2)
> *:last-child {
    text-align: center;
}

.container
> .row:nth-child(n + 2)
> *:last-child:not(:empty) {
    border-color: darkgreen;
    background-color: green;
    color: white;
}

.container
> .row:nth-child(n + 2)
> *:last-child:empty {
    border-color: firebrick;
    background-color: crimson;
}

.invisible {
    visibility: hidden;
}

.test {
    margin-top: 8px;
}

input[type="number"] {
    margin-top: 4px;
    text-align: right;
}

input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
    opacity: 1;
}
<head>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" rel="stylesheet">
</head>

<div class="container">
    <div class="row">
        <span>Text</span>
        <span>Offset</span>
        <span>Scroll</span>
        <span>Display</span>
        <span>Full</span>
        <span>Truncated</span>
    </div>
 
    <div class="row">
        <span>
            <a>Analytics reports comes through garbled. Plsss</a>
        </span>
        <span></span>
        <span></span>
        <span></span>
        <span></span>
        <span></span>
    </div>

    <div class="row">
        <span>
            <a>Analytics reports comes through garbled. Plsssssss</a>
        </span>
        <span></span>
        <span></span>
        <span></span>
        <span></span>
        <span></span>
    </div>

    <div class="row">
        <span>
            <a>Normal text</a>
        </span>
        <span></span>
        <span></span>
        <span></span>
        <span></span>
        <span></span>
    </div>
</div>

<div class="test">
    <strong>
        Try changing the width up or down a few pixels.<br />
    </strong>
    <label>
        Width:
        <input type="number" id="width" value="295" min="10" max="400" size="4" />
    </label>
</div>

Sign up to request clarification or add additional context in comments.

10 Comments

This doesn't seem to work with OP's case though, where they have inner elements inside the one they're checking. If I read it correctly, I think your code will work only when there is only bare TextNodes inside the target element. (And as with see sharpers's answer, this assumes that the style applied on the target would still apply on the clone.
Oh, and my answer does work cross-browser, it's unfortunately a bit more complex than it ought to be, but I think that's still what it needs to be.
I'm not sure why inner elements would cause a problem. I just edited my snippet and added <a> tags around the text, just like in the question, and it still works fine. Is there something I'm not thinking of?
And yes, this assumes the style applied on the target would apply on the clone, but it takes a little more care to ensure it will by adding the clone to the parent instead of to the body. Obviously, there are cases where this won't be sufficient (in fact, the snippet code comes close to being one of those cases, by nature of the fact that it uses :nth-child CSS selectors), but I'd wager that for the vast majority of cases, this will sufficiently apply the same styling, and handling those that it doesn't should generally be easy, on a case by case basis.
I mean that it doesn't work with OP's code. jsfiddle.net/mg91ps7e I didn't really took the time to investigate why it doesn't, but it doesn't. My solution does.
|
8

The problem here is that both HTMLElement.offsetWidth and Element.scrollWidth are rounded values.
Your element's true inner-width is actually 300.40625px on my computer, and this gets floored to 300px in my Chrome.

The solution here is to use APIs that return float values, and there aren't much...

One could be tempted to check the inner <a>'s getBoundingClientRect().width, and that would actually work in all OP's cases, but that would only work in these case: Add a padding to the div, a margin to these <a>, or an other element and it's broken.

document.querySelectorAll( ".test" ).forEach( el => {
  el.classList.toggle( "truncated", isEllipsisActive( el ) );
} );

function isEllipsisActive( el ) {
  return el.firstElementChild.getBoundingClientRect().width > el.getBoundingClientRect().width;
}
div.test {
  margin-bottom: 1em;
  background: red;
  color: #fff;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  width: 300px;
}
div.truncated {
  background: green;
}
.margin-left {
  margin-left: 225px;
}
<!-- should be green -->
<div class="test">
  <a>Analytics reports comes through garbled. Plsss</a>
</div>

<!-- should be green -->
<div class="test">
  <a>Analytics reports comes through garbled. Plsssssss</a>
</div>

<!-- should be green -->
<div class="test">
  <a>Analytics</a><a> reports comes through garbled. Plsssssss</a>
</div>

<!-- should be green -->
<div class="test">
  <a class="margin-left">Shorter text</a>
</div>

<!-- should be red -->
<div class="test">
  <a>Normal text</a>
</div>

So one might think a Range and its getBoundingClientRect() method would do, however, while this is able to tell the real size of the text content in your element, this only checks for the text content. If the scrolling is caused by a margin, it won't work.

document.querySelectorAll(".test").forEach( el => {
    el.classList.toggle( "truncated", isEllipsisActive( el ) );
} );

function isEllipsisActive( el ) {
  return el.scrollWidth !== el.offsetWidth ?
    el.scrollWidth > el.offsetWidth :
    checkRanges( el ); // Blink and Webkit browsers do floor scrollWidth
}

function checkRanges( el ) {
  const range = new Range();
  range.selectNodeContents( el );
  
  const range_rect = range.getBoundingClientRect();
  const el_rect = el.getBoundingClientRect();
  // assumes ltr direction
  return range_rect.right > el_rect.right;
}
div.test {
  margin-bottom: 1em;
  background: red;
  color: #fff;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  width: 300px;
}
div.truncated {
  background: green;
}
.margin-left {
  margin-left: 225px;
}

.margin-right {
  margin-right: 225px;
}
<!-- should be green -->
<div class="test">
  <a>Analytics reports comes through garbled. Plsss</a>
</div>

<!-- should be green -->
<div class="test">
  <a>Analytics reports comes through garbled. Plsssssss</a>
</div>

<!-- should be green -->
<div class="test">
  <a>Analytics</a><a> reports comes through garbled. Plsssssss</a>
</div>

<!-- should be green -->
<div class="test">
  <a class="margin-left">Shorter text</a>
</div>

<!-- should be green -->
<div class="test">
  <a class="margin-right">Shorter text</a>
</div>

<!-- should be red -->
<div class="test">
  <a>Normal text</a>
</div>

So the only solution I could think of relies on a Chrome specific behavior: They do expose the Client Rect of the rendered ellipsis in the result of Range.getClientRects().
So a way to know for sure, in Chrome, if the ellipsis is rendered, is to toggle the text-overflow property and check if this DOMRect appeared.

However, since this is a Chrome only behavior, we still need to check for the Range's bounding-box position for Safari.

document.querySelectorAll(".test").forEach( el => {
    el.classList.toggle( "truncated", isEllipsisActive( el ) );
} );

function isEllipsisActive( el ) {
  return el.scrollWidth !== el.offsetWidth ?
    el.scrollWidth > el.offsetWidth :
    checkRanges( el ); // Blink and Webkit browsers do floor scrollWidth
}

function checkRanges( el ) {
  const range = new Range();
  range.selectNodeContents( el );
  
  const range_rect = range.getBoundingClientRect();
  const el_rect = el.getBoundingClientRect();
  // assumes ltr direction
  if( range_rect.right > el_rect.right ) {
    return true;
  }
  // Following check would be enough for Blink browsers
  // but they are the only ones exposing this behavior.
  
  // first force ellipsis
  el.classList.add( "text-overflow-ellipsis" );
  // get all the client rects (there should be one for the ellipsis)
  const rects_ellipsis = range.getClientRects();
  // force no ellipsis
  el.classList.add( "text-overflow-clip" );
  const rects_clipped = range.getClientRects();
  // clean
  el.classList.remove( "text-overflow-ellipsis" );
  el.classList.remove( "text-overflow-clip" );
  // if the counts changed, the text is truncated
  return rects_clipped.length !== rects_ellipsis.length;
}
/* 2 new clasess to force the rendering of ellipsis */
.text-overflow-ellipsis {
  text-overflow: ellipsis !important;
}
.text-overflow-clip {
  text-overflow: clip !important;
}

div.test {
  margin-bottom: 1em;
  background: red;
  color: #fff;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  width: 300px;
}
div.truncated {
  background: green;
}
.margin-left {
  margin-left: 225px;
}
.margin-right {
  margin-right: 225px;
}
<!-- should be green -->
<div class="test">
  <a>Analytics reports comes through garbled. Plsss</a>
</div>

<!-- should be green -->
<div class="test">
  <a>Analytics reports comes through garbled. Plsssssss</a>
</div>

<!-- should be green -->
<div class="test">
  <a>Analytics</a><a> reports comes through garbled. Plsssssss</a>
</div>

<!-- should be green -->
<div class="test">
  <a class="margin-left">Shorter text</a>
</div>

<!-- should be green -->
<div class="test">
  <a class="margin-right">Shorter text</a>
</div>

<!-- should be red -->
<div class="test">
  <a>Normal text</a>
</div>


Small update

Since this CL Chrome doesn't expose the bounding box of the ellipsis in case the start range is 0, (which apparently is the case in the penultimate test in the above snippet).
This means that our workaround doesn't work in that special case anymore.

3 Comments

interesting approach, I will debug in order to see why the second test ( from bottom to top ) is failing on firefox and safari
getClientRects seems to have full bowser support now according to the link in your answer
@tettoffensive it already had full browser support when I wrote this answer (and even a long time before), the discrepancy only concerns how the rendered ellipsis is treated by Range#getClientRects(), where only Chrome will return a DOMRect for that rendered ellipsis, all other browsers ignoring it.
7

Try using

function isEllipsisActive(e) {
  var c = e.cloneNode(true);
  c.style.display = 'inline';
  c.style.width = 'auto';
  c.style.visibility = 'hidden';
  document.body.appendChild(c);
  const truncated = c.offsetWidth >= e.clientWidth;
  c.remove();
  return truncated;
}

It's hacky, but it works.

2 Comments

Not sure i could use this hacky way, but appreciate your time mate.
This assumes the element to be tested will have all its styles still applying the same when set as a direct child of body.
0

As shown in my Codepen, it's as simple as checking:

node.scrollHeight > node.clientHeight

function isTextTruncated(node){  
  const truncated = node.scrollHeight > node.clientHeight
  console.clear()
  console.log("is truncated: ", truncated)
}

// observe resize
const resizeObserver = new ResizeObserver(m => isTextTruncated(m[0].target))

resizeObserver.observe(truncatedContainer, { attributes: true })
p {
  --line-clamp: 4;
  
  display: -webkit-box;
  -webkit-line-clamp: var(--line-clamp);
  -webkit-box-orient: vertical;
  
  resize: horizontal;
  hyphens: auto;
  width: 300px;
  min-width: 200px;
  max-width: 90%;
  overflow: hidden;
}
<p id='truncatedContainer'>
  Lorem ipsum dolor sit amet, consectetur adipiscing elit, 
  sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 
  Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris
  nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
</p>

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.