Workaround to force CSS :hover to update after a transition (opening a menu)
Asked Answered
C

1

10

There are already several questions addressing this issue. I am including mine for two reasons:

  • It suggests a possible alternative solution
  • The demo code may be useful to others who want to simulate a menu

After a CSS transition, the user must move the mouse before the element that is now under the mouse will notice that it is in a :hover state. I have created a menu-like feature that slides open to show different options. The option under the mouse at the end of the opening transition is not the same as the one under the mouse at the start of the transition. I have thus had to find a workaround.

You can find a jsFiddle here and the demo source below. Look for WORKAROUND (in three places) to see what I have done.

To see the issue, move the mouse over the menu and then leave it in place, without moving it. The list item that the browser thinks is :hover will appear in blue. My workaround overrules the li:hover rule with an li.ignoreHover class. To make the workaround invisible, I can simply use the standard background colour. Instead, I am using blue to make the issue visible.

My question: I have noticed that pressing one of the modifier keys (Caps, Caps lock, Ctrl, Option/Alt, on Mac, ...) will also force the :hover state to update. Is there a way to send such an event to the #menu element?

(My attempts to do so have not been successful, so I prefer to give you my working workaround than one that may not be valid).

<!DOCTYPE html>
<html>
<head>
 <style>
#menu {
  position: relative;
  background: #ccc;
  display: inline-block;
}
#wrapper {
  margin: 5px;
}
#logo {
  width: 150px;
  height: 50px;
  border: 1px solid #000;
  margin: 0px auto;
  z-index: 10;
}
nav {
  width: 100%;
  overflow: hidden;
  text-align:center;
  height: 2em;
}
ul {
  position: relative;
  display:inline-block;
  margin: 0 auto;
  padding: 0;
  list-style-type: none;
  text-align:left;
}
li {
  display: block;
  margin: 0;
  padding:0.25em 0;
  line-height: 1.5em;
}
ul.animated, nav {
  transition: all 500ms linear 1s;
}
#menu.hover ul, #menu.hover nav {
  transition-delay: 0s;
}
li:hover,
li.hover {
  background-color: #999;
}
li.ignoreHover {
  background-color: #ccf; /* a touch of blue, so you can see it */
}
.selected {
  color: #fff;
}
 </style>
  <script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script> 
</head>
<body>
<div id="menu">
  <div id="wrapper">
    <div id="logo"></div>
  </div>
  <nav>
    <ul>
      <li>Note one</li>
      <li>Note two</li>
      <li>Note three</li>
      <li>Not four much longer</li>
      <li>Note five</li>
      <li>Note six</li>
    </ul>
  </nav>
</div>

<script>
var test = {}
;(function createMenu() {
    var item = 3;
    var minPadding = 5;
    var hover = "hover" // class

    var $li = $("li");
    var $ul = $("ul");
    var $menu = $("#menu");
    var $nav = $("nav");

    var itemHeight = parseInt($li.outerHeight(), 10);
    var itemCount = $ul.children().length;
    var menuWidth = $menu.outerWidth(true);
    var padding = (menuWidth - $ul.width()) / 2;

    var transitionDone = false;
    var mouseOver = false;
    var top;

    // Pad the list items to fill the width of the menu
    if (padding < minPadding) {
        // Widen the menu to allow for the minimum padding
        menuWidth += (minPadding - padding) * 2;
        $menu.width(menuWidth);
        padding = minPadding;
    }

    $li.css({
        paddingLeft: padding,
        paddingRight: padding
    });

    // Scroll to the current selected item
    selectItem(true);

    function selectItem(scroll) {
        $ul.children().removeClass("selected");
        $ul.children().eq(item).addClass("selected");

        if (scroll) {
            top = -(itemHeight * item);
            $ul.css({
                top: top
            });
        }
    }

    // Wait until the initial settings are applied 
    // before animating the transitions
    setTimeout(function () {
        $ul.addClass("animated");
    }, 1);

    // Handle interaction with the menu
    $menu.on("mouseover", openMenu);
    $menu.on("mouseleave", closeMenu);
    $menu.on("transitionend", menuIsOpen);
    $ul.on("click", treatClickOnItem);

    // <WORKAROUND...
    var x
    var y
    // ... WORKAROUND>


  function openMenu(event) {

        if (mouseOver) {
            // This method may be called multiple times as the menu is
            // transitioning to its open state
            return
        }

    // <WORKAROUND...
    $menu.on("mousemove", function updateXY(event) {
            x = event.pageX
            y = event.pageY
    })
    // ... WORKAROUND>

        $menu.addClass(hover);
        transitionDone = false;
        mouseOver = true;

        $nav.css({
            height: (itemHeight * itemCount)
        });
        $ul.css({
            top: 0
        });
    }

    function menuIsOpen() {
        transitionDone = true;

      // <WORKAROUND...   
    var $hover = $("li:hover").addClass("ignoreHover")
    var $item = $(document.elementFromPoint(x, y))
    if (mouseOver) {
      $item.addClass(hover)
    }
    $menu.on("mousemove", function () {
      $item.removeClass(hover)
      $hover.removeClass("ignoreHover")
      $menu.off("mousemove")
    })
        //... WORKAROUND>

        if (!mouseOver) {
            closeMenu()
        }
    }

    function closeMenu() {
        mouseOver = false;
        if (transitionDone) {
            $menu.removeClass(hover)

            $nav.css({
                height: itemHeight
            });
            $ul.css({
                top: top
            });
        }
  }

    function treatClickOnItem(event) {
        item = $(event.target).index();
        top = -(itemHeight * item);
        selectItem();
        // DO MORE STUFF WITH THE SELECTION
    }
})()
</script>
</body>
</html>
Coolidge answered 24/5, 2015 at 18:48 Comment(9)
The padding is the JS is to ensure that the menu and the logo (represented by a box) are compatible widths, regardless of the width of the logo and the length of the menu items. I've updated the question so that you can see the issue more clearly.Coolidge
Your issue is now clear after you explained how to get to it. Good move! Now, what means compatible widths? I'm not sure to get all your code...Grosberg
Compatible widths means that the whole #menu block is nicely presented with a margin, regardless of the relative widths of the logo and the text. You can try changing the item names, or the width in the #logo rule if you want to see the effect. The blueish colour is my way of making the worked-around issue visible. The code that needs attention is marked WORKAROUND. I'd be interested to see your couple of jQuery lines that can handle all the rest : )Coolidge
I'll be glad to show you. Tell me just, so all you need is actually a menu that remembers the last clicked item (Also that has the right hover color states), right?Grosberg
Also what I don't understant is the difference between items having: :hover color #999 and the strange .ignoreHover set to that blueish #ccf. What is that .ignoreHover for? ThanksGrosberg
If you remove .ignoreHover, you will see what happens, that shouldn't happen. My question is about sending an modifier key event, to force an update to the :hover element, just the way it happens when you press a modifier key.Coolidge
Let us continue this discussion in chat.Coolidge
as a workaround you could probably event.preventDefault on all mouse events for the necessary timeout for the menu to openUnworldly
Browser ? I can't reproduce this in Firefox or ChromeBlenheim
G
3

jsBin demo

Seems like it's almost impossible to get the :hover state of an element while it's animating.

Remove that :hover from CSS and
create instead a class .hover with the desired styles. use jQuery to toggle .hover:

$links.hover(function(){
  $(this).toggleClass("hover");
});

Now back to your issue:

In order to get the right element highlighted once the menu opens
we need to always know the mouse Y position:

var mouseY = 0; // Needed to know the mouse position when menu is opening
$(document).on("mousemove", function( e ){
    mouseY = e.clientY; // Update the Y value
});

now, on collapsed menu hover, animate your menu using jQuery,
inside the animate step callback get on every frame each link position, .filter() them by targeting the one that matches the mouse position.
And finally apply the .hover to only that one:

function openMenu() {
  $navUl.stop().animate({top: 0});
  $nav.stop().animate({height: linkH*nLinks}, {
    duration: 600,
    step: function( menuHeight ){
        // keeps removing and adding class during the animation time.
        // (it's an overkill but no other solution to that issue so far)
        $links.removeClass("hover").filter(function(i, e){
          var t = e.getBoundingClientRect().top;
          return mouseY > t  &&  mouseY < t+linkH;
        }).addClass("hover"); // only to the link returned by `.filter()` condition
    }
  });
}

! important note: the above filtering will be as expensive as many items you have, cause at every animate frame it tries to get the positions. If you detect slowliness - improve the above.

To recap
at every frame check if the mouse clientX/Y coordinates are inside the element's element.getBoundingClientRect() coordinates/values

Grosberg answered 24/5, 2015 at 23:55 Comment(1)
Thank you for all the time you spent rewriting my demo completely, for confirming that my approach of keeping track of the mouse position is valid, and for demonstrating how to use step inside a jQuery animation. JavaScript doesn't provide a native step callback for CSS animations, so jQuery has to intensively manage its own tweening using setTimeout continuously. My solution relies on the browser to run CSS animations natively, and then makes one check on transitionend. This might look less elegant than a single jQuery call, but it seems kinder on the CPU : )Coolidge

© 2022 - 2024 — McMap. All rights reserved.