16

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.

Nidhin Joseph
  • 9,981
  • 4
  • 26
  • 48

3 Answers3

9

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.

Kaiido
  • 123,334
  • 13
  • 219
  • 285
  • interesting approach, I will debug in order to see why the second test ( from bottom to top ) is failing on firefox and safari – que1326 Jul 01 '21 at 11:04
  • getClientRects seems to have full bowser support now according to the link in your answer – tettoffensive Jan 25 '23 at 04:09
  • @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. – Kaiido Jan 25 '23 at 04:51
6

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.

see sharper
  • 11,505
  • 8
  • 46
  • 65
3

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>
P Daddy
  • 28,912
  • 9
  • 68
  • 92
  • 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. – Kaiido Jan 25 '23 at 05:04
  • 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. – Kaiido Jan 25 '23 at 05:13
  • I'm not sure why inner elements would cause a problem. I just edited my snippet and added `` tags around the text, just like in the question, and it still works fine. Is there something I'm not thinking of? – P Daddy Jan 27 '23 at 05:52
  • 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. – P Daddy Jan 27 '23 at 05:54
  • I mean that it doesn't work with OP's code. https://jsfiddle.net/mg91ps7e/ I didn't really took the time to investigate why it doesn't, but it doesn't. [My solution does](https://jsfiddle.net/mg91ps7e/1). – Kaiido Jan 27 '23 at 06:04
  • Ok, it doesn't work because it doesn't change the width of the `temp` element. So this won't work with anything that hasn't a `width: auto`, which is arguably probably quite common for overflown elements. – Kaiido Jan 27 '23 at 06:16
  • That's a good catch! But it's quite easy to add `temp.style.width = "auto"`, which fixes the example in your fiddle. – P Daddy Jan 27 '23 at 06:19
  • By the way, the final snippet in your answer is currently failing for me in Chrome. The second `
    ` from the bottom is red and it displays as "Shorter text...". I'm not sure why. It works fine in Firefox.
    – P Daddy Jan 27 '23 at 06:20
  • But then that fails if the width is set through `max-width` or other means. As for Chrome's issue I'll check that, thanks for the notice. Though this one (ellipsis caused by margin-right) seems more like a corner case, browsers don't even agree if they should show the ellipsis or not. – Kaiido Jan 27 '23 at 06:28
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/251427/discussion-between-p-daddy-and-kaiido). – P Daddy Jan 27 '23 at 06:29