Dropdown and scrolls

in Archive

Creating dropdown menus inside an scrollable element

I don’t usually write about work. I guess I try to get away of it every time I leave the office. But I would be lying if I said I didn’t like to code. I actually do, and I find problem solving the most appealing and enjoyable part of my job. So I decided that from time to time it wouldn’t be so bad to write about what I do for most of my weekdays. I feel it can help me review and reassure (or not) decisions I made when a problem presented itself.

The problem

I’m making responsive little portions of our in-house CRM. Making our CRM’s main features available on-the-go for our sales team would be a great improvement for the company. A CRM is not a normal website, is closer to a desktop application, and the type of data and content is not the same you usually display on a website.

One of the first issues I found was that our toolbar(s) were way to wide for a mobile device. We have a bunch of buttons, search boxes, dropdowns etc. The first impulse would be to move all the content inside a panel of sorts as we commonly do with menus. But due to the contents of the toolbar that would be difficult to implement and maintain some kind of ease of use (and it would look awful).

The solution

I opted to just add a scroll to our toolbars whenever was needed. This way we keep the same user experience that we have in desktop, we avoid pop ups and panels and we keep the whole system consistent. It is pretty intuitive to just scroll sideways on a toolbar, and I think it is the best solution.

But this solution forced me to do some workarounds. Adding a scroll was ease and useful (overflow-x:auto), but also created a new issue. It turns out that the values of overflow-x and overflow-y are dependent of each other. That means that when x or y is set to hidden, scroll or auto, the other one can’t be visible. It is automatically changed to hidden, no matter what you specify on your CSS.

The way CSS works is sometimes confusing, and this is one of this cases. Thankfully, there’s a little trick to make it work. It requires some javascript to define the position of the menus, but nothing to complex.

To start, we need to create a toolbar with an inner div and a dropdown menu inside of it.

<div class="toolbar">
    <div class="toolbar__inner">
      <div class="dropdown">
        Dropdown menu 1
        <div class="dropdown__menu">
          <ul>
            <li>Option 1</li>
            <li>Option 2</li>
            <li>Option 3</li>
            <li>Option 4</li>
          </ul>
        </div>
      </div>
    </div>
</div>

The toolbar div (position:relative) will be the one that we use to position our dropdowns (position:absolute). The toolbar__inner is the one that will serve as the scrollable menu (overflow-x:auto). Because the dropdown__menu has absolute position and the offset parent is toolbar that has overflow: visible, the scrolling of toolbar__inner can work and not interfere with the visibility of the menus.

You can see the full CSS here:

*{
  box-sizing:border-box;
}
.wrapper{
  width:800px;
}
.wrapper--small{
  width:300px;
}
.toolbar{
  position:relative;
  width:100%;
}
.toolbar__inner{
  background:#ddd;
  border:1px solid #aaa;
  width:100%;
  padding:10px;
  overflow-x:auto;
  white-space: nowrap;
}
.dropdown, button{
  display:inline-block;
  font-size:15px;
  line-height:15px;
  font-family:arial;
  padding:10px;
  border:1px solid #aaa;
  background:#eee;
  cursor:pointer;
}
.dropdown__menu{
  position:absolute;
  display:none;
  overflow:hidden;
}
.dropdown--active .dropdown__menu{
  display:block;  
}
.dropdown__menu ul{
  padding:0px;
  margin:0px;
  list-style:none;
  background:#fff;
  border:1px solid #aaa;
}
.dropdown__menu ul li{
  padding:10px;
  width:100%;
  box-sizing:border-box;
}
.dropdown__menu ul li:hover{
  background:#eee;
}

Because the offset parent is not the dropdown button but the toolbar, we need to use JS to position the dropdown menus in the right place. Also, we need to have in mind that being inside an scrollable div, the position of the dropdown menus can be different every time we open them and every time we scroll. The code to do all this is a bit longer that I expected in the beginning. Let’s see the code and then briefly comment on what every bit of code does:

(function(){
  let dropdown = document.querySelectorAll('.dropdown');
  
  dropdown.forEach(function(node, index){
    let _this = node;
    let menu = _this.querySelector('.dropdown__menu');
    let container = getScrollable(_this);
    /*
    * Dropdown on click
    */
    _this.addEventListener('click', function(e){
      let activeDropdown = document.querySelector('.dropdown.dropdown--active');
      //close other dropdowns if opened
      if(activeDropdown && activeDropdown != _this){
        activeDropdown.classList.remove('dropdown--active');
      }
      //toggle dropdown visibility
      _this.classList.toggle('dropdown--active');
      //Update the position of the dropdown menu
      setDropdownPosition(_this, container);
    });
    /*
    * Stop propagation of click inside the dropdown to avoid the dropdown closing when clicking on one of the options
    */
    menu.addEventListener('click', function(e){
      e.stopPropagation();                       
    });
    /*
    * If the dropdown is inside an scrollabe element
    * we want to change the position of the dropdown menus on scroll
    */
    if(container){
      container.addEventListener('scroll', function(){
        setDropdownPosition(_this, container);
      });
    }
  });
  /*
  * Close dropdown when clicking outside
  */
  document.addEventListener('click', function(e){
    let activeDropdown = document.querySelector('.dropdown.dropdown--active');
    
    if(activeDropdown){
      if(!(e.target.classList.contains('dropdown') || hasParentWithClass(e.target, 'dropdown'))){
        activeDropdown.classList.remove('dropdown--active');
      }
    }
  });
})();
/*
* Control dropdown menu positioning
*/
function setDropdownPosition(dropdown, container){
  let menu = dropdown.querySelector('.dropdown__menu');

  let topPosition = dropdown.offsetTop + dropdown.offsetHeight;
  let leftPosition = dropdown.offsetLeft;
  let rightPosition = dropdown.offsetLeft + dropdown.offsetWidth;
  let scrollPosition = container.scrollLeft;
  let position = 0;
  
  menu.style.top = topPosition + 'px';
  // Define position
  if(menu.classList.contains('right')){
    position = rightPosition - scrollPosition - menu.offsetWidth;
  }else{
    position = leftPosition - scrollPosition;
  }
  //Visibility depends on the scroll
  if(position < 0 || position > dropdown.offsetParent.offsetWidth){
    menu.style.height = '0px';
  }else{
    menu.style.height = 'auto';
  }
  
  menu.style.left = position + 'px';
}
/*
* Check if a node has a parent element with x classname
*/
function hasParentWithClass(node, className){
  let parent = node.parentNode;
  
  while(parent != document){
    if(parent.classList.contains(className)){
      return true;
    }
                                             
    parent = parent.parentNode;
  }
       
  return false;
}
/*
* Find a parent element (inside the offsetparent) that has a scroll
*/
function getScrollable(node){
  let container = node.offsetParent;
  let parent = node.parentNode;
  let overflowStates = ['auto', 'scroll'];
  
  while(parent != container){
    if(overflowStates.indexOf(parent.style.overflow)){
      return parent;
    }
                                             
    parent = parent.parentNode;
  }
       
  return false;
}

dropdown click event: closes other opened dropdowns and toggles the one that has been clicked. Calls the setDropdownPosition() function.

container scroll event: we add an scroll event to the scrollable parent of each dropdown. On scroll, we call the setDropdownPosition(). To get the right scrollable container we use the method getScrollable().

setDropdownPosition(node, container): it sets the position of the node (dropdown menu) inside it’s offset parent. We get the dropdown item offsetLeft and the we substract the scrollLeft position of the scrollable parent of the dropdown item. There’s a little variation in case we want the dropdown menu to be aligned to the right side of the dropdown item.

getScrollable(node): it returns the parent element of the node (dropdown item) that has overflow auto/scroll and is at the same time inside the node’s offset parent.

There are a couple of extra features in the code above (dropdown item click behaviour, out of dropdown click…), but is to improve the behaviour of the dropdown and not strictly necessary.

The final result

Change between “mobile” and “desktop” by clicking on the toggle button. This code can easily be translated into a JS class or jQuery plugin.

See the Pen Scrollable responsive toolbar with dropdown items by Francisco Canete (@Francisco_caal) on CodePen.

If you have any ideas to improve the code or solutions that you think are better for this same issue, please leave a comment below or contact me on SM 🙂

Photo by Kobu Agency on Unsplash

Write a Comment

Comment