0

The fiddle below contains the code responsible for expanding a collapsible menu. It is similar to Bootstrap's Collapse component. Here's how it works:

  1. The menu is hidden at first with display: none.
  2. display: none is removed, making the menu visible so we can retrieve and store its current height for later use.
  3. The menu is given the transition CSS property configured for the height property, which is now being set to 0.
  4. The height of the menu is set to the value we retrieved in step 2.
  5. The transitionend event handler should kick in once the transition ends.

Although the menu expands to the expected height, it does that without our transition. I need your help to figure out why.

Here, try it: https://jsfiddle.net/avkdb89j/3/

An important observation: if you open the inspector tool, select the <nav> (the menu) and toggle the height inline style that our code adds after you click the button, the transition is immediately triggered, and the transitionend handler is executed. Could it be a race condition here? The height is supposed to change after the transition is set, not before.

crimson_king
  • 269
  • 3
  • 12

1 Answers1

1

You need to get rid of the display: none and let css take care of the transitions more.

I set the .np-collapsible to this in order to let the element always exist:

.np-collapsible:not(.np-expanded) {
  height: 0;
  overflow: hidden;
}

I set the transition class to not make any changes that would start up a transition (it only includes the transition property).

Then in the JS, the transition is done similarly to what you had originally, but the main difference is that I use menu.scrollHeight to get the height of the menu in order to avoid having extra transitions to get the height normally.

I also added the ability to contract the menu to the function. In case of contracting the menu, you have to remove np-expanded class before the transition due to the :not(.np-expanded) selector earlier stopping the overflow: none.

  // Retrieve the height of the menu
  var targetHeight = menu.scrollHeight + 'px';
  if (menu.classList.contains('np-expanded')) {
    // It's already expanded, time to contract.
    targetHeight = 0;
    menu.classList.remove('np-expanded');
    button.setAttribute('aria-expanded', false);
  }
  // Enable transition
  menu.classList.add('np-transitioning');
  menu.addEventListener('transitionend', function(event) {
    // Disable transition
    menu.classList.remove('np-transitioning');

    // Indicate that the menu is now expanded
    if (targetHeight) {
      menu.classList.add('np-expanded');
      button.setAttribute('aria-expanded', true);
    }
  }, {
    once: true
  });

  // Set the height to execute the transition
  menu.style.height = targetHeight;

Here's a working example:

var button = document.querySelector('.np-trigger');
var menu = document.querySelector(button.dataset.target);

button.addEventListener('click', function(event) {
  expand();
}, false);

function expand() {
  if (isTransitioning()) {
    // Don't do anything during a transition
    return;
  }

  // Retrieve the height of the menu
  var targetHeight = menu.scrollHeight + 'px';
  if (menu.classList.contains('np-expanded')) {
    // It's already expanded, time to contract.
    targetHeight = 0;
    menu.classList.remove('np-expanded');
    button.setAttribute('aria-expanded', false);
  }
  // Enable transition
  menu.classList.add('np-transitioning');
  menu.addEventListener('transitionend', function(event) {
    // Disable transition
    menu.classList.remove('np-transitioning');

    // Indicate that the menu is now expanded
    if (targetHeight) {
      menu.classList.add('np-expanded');
      button.setAttribute('aria-expanded', true);
    }
  }, {
    once: true
  });

  // Set the height to execute the transition
  menu.style.height = targetHeight;
}

function isTransitioning() {
  if (menu.classList.contains('np-transitioning')) {
    return true;
  }

  return false;
}
.np-collapsible:not(.np-expanded) {
  height: 0;
  overflow: hidden;
}

.np-transitioning {
  transition: height 0.25s ease;
}

.navigation-menu {
  display: flex;
  flex-direction: column;
  position: fixed;
  top: 4rem;
  left: 1rem;
  width: 270px;
}
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
<button class="btn btn-dark np-trigger" data-target="#menu">Menu</button>

<nav id="menu" class="bg-dark navigation-menu np-collapsible">
  <a class="nav-link" href="#">Link</a>
  <a class="nav-link" href="#">Link</a>
  <a class="nav-link" href="#">Link</a>
</nav>

As was mentioned, there was a race condition in the original, because there were multiple transitions being started by setting the classes as they were originally. This method avoids issues with display: none and other race conditions by keeping it simple.

Interestingly enough, moving the menu.classList.add('np-transitioning) line up to the same spot as removing np-collapsible allows it to work. I feel like it could just be that the changes were essentially batched together at that point. The reason the original jquery code worked, is likely because it had the classes being added/removed without any other DOM work in-between.

Here's the original updated to work in the method mentioned above https://jsfiddle.net/5w12rcbh/

Here's that same update, but expanded and cleaned up a little using methods like classList.replace to perform more work at once. This adds the same toggle ability as my original snippet. https://jsfiddle.net/bzc7uy2s/

Zachary Haber
  • 10,376
  • 1
  • 17
  • 31
  • Your method is simple and effective, indeed. I just wish I could keep it working the way Bootstrap does in its Collapse component. They hide the element with `display: none`. My code here was made to work similarly. I don't know if the use of jQuery makes any difference there. My code is part of the `nav-panel` nodejs module and is being rewritten to drop jQuery completely. It was functional before. Here's how it was: https://github.com/o-alquimista/nav-panel/blob/master/index.js#L194 and here's a working example, try the floating menu on my portfolio website: https://dougsilva.me/. – crimson_king May 19 '20 at 23:48
  • I still wonder why my older code written with jQuery works, and this one with pure JavaScript doesn't. In any case, after looking at the Bootstrap Collapse source code, I see that it's actually pure JavaScript nowadays, so I must study it. The answer to my question is probably there. I'm marking your answer as the solution anyways. – crimson_king May 20 '20 at 00:07
  • 1
    @crimson_king, I've updated and added a couple new fiddles to the end of the post. It's actually a super simple change to get the original code working. – Zachary Haber May 20 '20 at 02:39
  • Nice! Thank you, that will help a lot. – crimson_king May 20 '20 at 10:40