javascriptcssstickygetboundingclientrect

Chrome getBoundingClientRect producing inconsistent results when used in sticky table cells


I am attempting to re-create a design system in SCSS/JavaScript that has the following requirements:

  1. Data is displayed in a table
  2. The table is horizontally-scrollable to always be contained in the viewport
  3. The right-most table column is positioned as sticky and contains a hover-menu
  4. The hover-menu uses absolute positioning and some JavaScript to break out of the sticky container and appears alongside the hovered menu button (based on a previous question).

After studying a multitude of tutorials and attempting various approaches I am about 99% complete, apart from a curious issue with Chrome (Firefox works perfectly).

Firefox (working fine)

enter image description here

Chrome (works for first few only)

In my sticky table cell, when the cursor reaches a row that contains wrapped text, the menu suddenly appears in the wrong place (always 17 pixels too far right). Stranger still, on the first hover it appears correctly but then subsequently in the wrong place, as are all the following row menus:

enter image description here

I believe the issue is JavaScript-related rather than CSS, as the console appears to produce different values for the menu button's left position (shown in video).

CodePen repro

https://codepen.io/evildr/pen/LYvxBMJ

I'd be very grateful if anyone can tell me what is actually causing the discrepancy to occur, and why Chrome/Firefox behave differently?

function WireUpKebabMenusOnMouseOver() {
    
    // Select all elements with class 'MenuContainer'
    var menuContainers = document.querySelectorAll('.MenuContainer');

    // Remove any existing mouseover event listeners
    menuContainers.forEach(function (menuContainer) {

        var menuButton = menuContainer.querySelector('button');
        var wrapperDiv = menuContainer.querySelector('.wrapper');

        if (menuButton) {
            menuButton.removeEventListener('mouseover', handleMouseOver);
            menuButton.removeEventListener('mouseleave', handleMouseLeave);
            menuButton.addEventListener('mouseover', handleMouseOver);
            menuButton.addEventListener('mouseleave', function (event) {
                handleMouseLeave(event, menuContainer, wrapperDiv);
            });
        }

        if (wrapperDiv) { 
            wrapperDiv.removeEventListener('mouseleave', handleMouseLeave);
            wrapperDiv.addEventListener('mouseleave', function (event) {
                handleMouseLeave(event, menuContainer, wrapperDiv);
            });
        }
    });
}

function handleMouseLeave(event, menuContainer, wrapperDiv) {

    // find the menu button within the current container (e.g. this row's menu button)
    var button = menuContainer.querySelector('button');
    
    // Don't hide the menu if the cursor is on the button or the menu
    if (wrapperDiv.matches(':hover') || button.matches(':hover')) {
        return false;
    }

    if (wrapperDiv) {
        // Hide the menu
        wrapperDiv.classList.remove("ShowMenu");
        menuContainer.appendChild(wrapperDiv); // Put the menu wrapper back in its original place so that mouseenter works next time.
    }
}

function handleMouseOver(event) {

    var menuButton = event.target;

    if (menuButton) {

        var menuContainer = menuButton.closest('.MenuContainer');
        var wrapperDiv = menuContainer.querySelector('.wrapper');

        if (wrapperDiv) {
            
            // Show the menu
            
            wrapperDiv.classList.add('ShowMenu');
            
            // Position the menu alongside the button
            
            var menuButtonPos = menuButton.getBoundingClientRect();
            var wrapperDivPos = wrapperDiv.getBoundingClientRect();
            
            console.log("Button left = " + menuButtonPos.left);
            
            var newMenuPosLeft = menuButtonPos.left - wrapperDivPos.width + 5;
            var newMenuPosTop = menuButtonPos.top + window.scrollY;

            var resultsTable = wrapperDiv.closest('table') 
            if (resultsTable) {

                var searchResultsGridComponent = resultsTable.closest('.SearchResultsGridComponent');

                if (searchResultsGridComponent) {
                    // Move menu's DOM position outside the table so that it is no longer constrained by the table parent's scroll boundary.
                    searchResultsGridComponent.appendChild(wrapperDiv);
                }
            }

            wrapperDiv.style.top = newMenuPosTop + 'px';
            wrapperDiv.style.left = newMenuPosLeft + 'px';

            // // TODO: Recheck menu position now it has been positioned
            // wrapperDivPos = wrapperDiv.getBoundingClientRect();
            // var bottomEdge = window.scrollY + window.innerHeight;

            // if (wrapperDivPos.bottom > bottomEdge) {
            //  var amountOffScren = (wrapperDivPos.bottom - bottomEdge);
            //  wrapperDiv.style.top = (wrapperDivPos.top - amountOffScren) + 'px';
            // }
        }
    }
}

WireUpKebabMenusOnMouseOver();
.SearchResultsGridComponent {
  padding: 1rem;
  width: fit-content;
  max-width: 100%;
}
.SearchResultsGridComponent .SearchResultsGrid {
  max-width: 100%;
  overflow-x: auto;
}
.SearchResultsGridComponent .SearchResultsGrid table {
  border-collapse: separate;
  border-spacing: 0;
  border: 1px solid #dededf;
  background-color: #fff;
}
.SearchResultsGridComponent .SearchResultsGrid table thead tr th {
  padding: 1rem;
  text-align: left;
  background-color: #dededf;
}
.SearchResultsGridComponent .SearchResultsGrid table tbody tr {
  border-top: 1px solid #dededf;
}
.SearchResultsGridComponent .SearchResultsGrid table tbody tr td {
  padding: 1rem;
  border: 1px solid #eee;
}
.SearchResultsGridComponent .SearchResultsGrid table tbody tr td.KebabMenuContainer {
  background-color: #eee;
  border: 1px solid #ddd;
  position: sticky;
  right: -0.2rem;
}
.SearchResultsGridComponent .SearchResultsGrid table tbody tr td.KebabMenuContainer button {
  background-image: url("/Images/Icons/KebabMenu.svg");
  background-position: center center;
}

.MenuContainer {
  line-height: 0;
  border: 1px dotted #aaa;
}
.MenuContainer button {
  object-fit: cover;
  border: none;
  background-color: #fff;
  height: 3rem;
  width: 3rem;
  background-repeat: no-repeat;
  border: 1px solid green;
}

.wrapper {
  position: absolute;
  /* Break out of normal page flow */
  z-index: 5;
  display: none;
  border: 1px solid red;
  background-color: #eee;
}
.wrapper > ul {
  margin: 0;
  padding: 0;
  list-style-type: none;
}
.wrapper > ul li {
  margin: 0;
  padding: 0;
}
.wrapper > ul li:not(:first-of-type) {
  border-top: 0.1rem solid #e8e8e8;
}
.wrapper > ul li a {
  display: block;
  padding: 1rem;
  white-space: nowrap;
  text-decoration: none;
}
.wrapper > ul li a:hover {
  background-color: #e8e8e8;
}
.wrapper > ul li label {
  padding: 1rem;
  white-space: nowrap;
}
.wrapper > ul li label:hover {
  background-color: #e8e8e8;
}

.wrapper.ShowMenu {
  display: block;
}
<div class="SearchResultsGridComponent">
    <div class="SearchResultsGrid">
        <table role="table">
            <thead role="rowgroup">
                <tr>
                    <th role="columnheader">ID</th>
                    <th role="columnheader">Name</th>
                    <th role="columnheader">Surname</th>
                    <th role="columnheader">Long value</th>
                    <th role="columnheader"></th>
                </tr>
            </thead>

            <tbody role="rowgroup">
                <tr role="row">
                    <td role="cell">
                        1
                    </td>
                    <td role="cell">
                        Bob
                    </td>
                    <td role="cell">
                        Down
                    </td>
                    <td role="cell">

                    </td>
                    <td role="cell" class="MenuContainer KebabMenuContainer"><button type="button">Menu</button>
                        <div class="wrapper">
                            <ul>
                                <li><a href="#">Menu Action 1</a></li>
                                <li><a href="#">Menu Action 2</a></li>
                                <li><a href="#">Menu Action 3</a></li>
                                <li><a href="#">Menu Action 4</a></li>
                                <li><a href="#">Menu Action 5</a></li>
                                <li><a href="#">Menu Action 6</a></li>
                                <li><a href="#">Menu Action 7</a></li>
                            </ul>
                        </div>
                    </td>
                </tr>
                <tr role="row">
                    <td role="cell">
                        2
                    </td>
                    <td role="cell">
                        Sandy
                    </td>
                    <td role="cell">
                        Beach
                    </td>
                    <td role="cell">
                        Short text
                    </td>
                    <td role="cell" class="MenuContainer KebabMenuContainer"><button type="button">Menu</button>
                        <div class="wrapper">
                            <ul>
                                <li><a href="#">Menu Action 1</a></li>
                                <li><a href="#">Menu Action 2</a></li>
                                <li><a href="#">Menu Action 3</a></li>
                                <li><a href="#">Menu Action 4</a></li>
                                <li><a href="#">Menu Action 5</a></li>
                                <li><a href="#">Menu Action 6</a></li>
                                <li><a href="#">Menu Action 7</a></li>
                            </ul>
                        </div>
                    </td>
                </tr>
                <tr role="row">
                    <td role="cell">
                        3
                    </td>
                    <td role="cell">
                        I.P.
                    </td>
                    <td role="cell">
                        Freely
                    </td>
                    <td role="cell">
                        <strong>Removing this long text fixes the problem</strong><br/>Lorem ipsum dolor sit amet, nam melius interpretaris an, sed no eripuit bonorum petentium. Lorem ipsum dolor sit amet, nam melius interpretaris an, sed no eripuit bonorum petentium.
                    </td>
                    <td role="cell" class="MenuContainer KebabMenuContainer"><button type="button">Menu</button>
                        <div class="wrapper">
                            <ul>
                                <li><a href="#">Menu Action 1</a></li>
                                <li><a href="#">Menu Action 2</a></li>
                                <li><a href="#">Menu Action 3</a></li>
                                <li><a href="#">Menu Action 4</a></li>
                                <li><a href="#">Menu Action 5</a></li>
                                <li><a href="#">Menu Action 6</a></li>
                                <li><a href="#">Menu Action 7</a></li>
                            </ul>
                        </div>
                    </td>
                </tr>
                <tr role="row">
                    <td role="cell">
                        4
                    </td>
                    <td role="cell">
                        Ivana
                    </td>
                    <td role="cell">
                        Beer
                    </td>
                    <td role="cell">
                        <img src="https://placehold.co/600x50/png" alt="Placeholder image to force table container scrolling" /> Wide image to force table scrolling
                    </td>
                    <td role="cell" class="MenuContainer KebabMenuContainer"><button type="button">Menu</button>
                        <div class="wrapper">
                            <ul>
                                <li><a href="#">Menu Action 1</a></li>
                                <li><a href="#">Menu Action 2</a></li>
                                <li><a href="#">Menu Action 3</a></li>
                                <li><a href="#">Menu Action 4</a></li>
                                <li><a href="#">Menu Action 5</a></li>
                                <li><a href="#">Menu Action 6</a></li>
                                <li><a href="#">Menu Action 7</a></li>
                            </ul>
                        </div>
                    </td>
                </tr>
                <tr role="row">
                    <td role="cell">
                        5
                    </td>
                    <td role="cell">
                        Verner
                    </td>
                    <td role="cell">
                        Werner
                    </td>
                    <td role="cell">

                    </td>
                    <td role="cell" class="MenuContainer KebabMenuContainer"><button type="button">Menu</button>
                        <div class="wrapper">
                            <ul>
                                <li><a href="#">Menu Action 1</a></li>
                                <li><a href="#">Menu Action 2</a></li>
                                <li><a href="#">Menu Action 3</a></li>
                                <li><a href="#">Menu Action 4</a></li>
                                <li><a href="#">Menu Action 5</a></li>
                                <li><a href="#">Menu Action 6</a></li>
                                <li><a href="#">Menu Action 7</a></li>
                            </ul>
                        </div>
                    </td>
                </tr>
                <tr role="row">
                    <td role="cell">
                        6
                    </td>
                    <td role="cell">
                        Long
                    </td>
                    <td role="cell">
                        Thyme
                    </td>
                    <td role="cell">

                    </td>
                    <td role="cell" class="MenuContainer KebabMenuContainer"><button type="button">Menu</button>
                        <div class="wrapper">
                            <ul>
                                <li><a href="#">Menu Action 1</a></li>
                                <li><a href="#">Menu Action 2</a></li>
                                <li><a href="#">Menu Action 3</a></li>
                                <li><a href="#">Menu Action 4</a></li>
                                <li><a href="#">Menu Action 5</a></li>
                                <li><a href="#">Menu Action 6</a></li>
                                <li><a href="#">Menu Action 7</a></li>
                            </ul>
                        </div>
                    </td>
                </tr>
            </tbody>
        </table>
    </div>
</div>


Solution

  • Update - issue identified

    It has emerged that Chrome calculates in advance where the scroll bar would be if it was required, and neglects the fact that the hover element is no longer in its original place. That is why the top few menus appear correctly but the latter ones don't - because the latter ones would need the scroll bar if positioned in the normal document flow.

    The different behaviour in Firefox is due to Firefox using the operating system scroll bar ('overlay' scroll bar in Windows), whereas Chrome uses the 'classic scrollbar' approach ([detailed by Google here][4]). Overlay scroll bars render over content and do not affect positioning.

    The workaround (for now) is to force overflow-y: scroll to force the vertical scrollbar to always appear.