Toggle An Element And Also Remove Its Visibility When Clicking Outside Of The Element - JavaScript

Viewed 304

I have a navigation with two items that have submenus. I currently have a class that toggles on and off that shows these submenus when clicked.

I would like it so when I click anywhere on the page they disappear if they are visible.

At the moment I think my code is a bit long-winded for what it currently achieves and perhaps it would be better to use e.target when clicking?

You can currently toggle the menus off-and-on by clicking either menu-item (this includes clicking the visible menu item a second time).

I thought to remove the 'visible' class by clicking outside of the menu-item I could do a simple document.addEventListener('click', function(e) {}) on the entire document to remove the 'visible' class if it was showing, but that doesn't seem to work.

Note: I need to do this without using a blur event listener

Codepen: https://codepen.io/emilychews/pen/bGWVVpq

var menu_item_1 = document.getElementById('item-1'),
    menu_item_2 = document.getElementById('item-2'),
    sub_menu_item_1 = document.getElementById('sub-item-1'),
    sub_menu_item_2 = document.getElementById('sub-item-2')

if (menu_item_1) {
      menu_item_1.addEventListener('click', function(e){
        sub_menu_item_1.classList.toggle('visible')

       // hide submenu 2
        sub_menu_item_2.classList.remove('visible')
    }, false)
}

if (menu_item_2) {
      menu_item_2.addEventListener('click', function(e){
        sub_menu_item_2.classList.toggle('visible')

        // hide submenu 1
        sub_menu_item_1.classList.remove('visible')
    }, false)
}
body {
  display: flex;
  justify-content: center;
  margin: 0;
  height: 100vh;
  width: 100%;
}

header {
  margin-top: 2rem;
  display: flex;
  width: 50%;
  justify-content: space-evenly;
  align-items: center;
  padding: 1rem;
  background: red;
  height: 2rem;
}

.menu-item {
  position: relative;
  padding: 1rem;
  background: yellow;
  cursor: pointer;
}

.submenu {
  display: none; /* changes to 'block' with javascript */
  padding: 1rem;
  background: lightblue;
  position: absolute;
  top: 4rem;
  left: 0;
  width: 6rem;
}

.submenu.visible {
  display:block;
}
<header>
  <div id="item-1" class="menu-item menu-item-1">ITEM 1
    <div id="sub-item-1" class="submenu submenu-1">SUB-ITEM-1</div>
  </div>
  <div id="item-2" class="menu-item menu-item-2">ITEM 2
    <div id="sub-item-2" class="submenu submenu-2">SUB-ITEM-2</div>
  </div>
</header>

3 Answers

There are a few different ways to achieve this, not all of which involve JS, I'll outline a few possible approaches below:

Pure CSS:

The first (and most likely easiest) is to use css-only. This again uses tabindex="-1" like Samuel's answer to make your menu item buttons focusable. Once a button is focused, you can apply some CSS to the focused item's associated submenu using the :focus pseudo-class selector:

.menu-item:focus > .submenu { /* select the focused menu-item's child elements with the class submenu */
  display: block;
}

See example below:

body {
  display: flex;
  justify-content: center;
  margin: 0;
  height: 100vh;
  width: 100%;
}

header {
  margin-top: 2rem;
  display: flex;
  width: 50%;
  justify-content: space-evenly;
  align-items: center;
  padding: 1rem;
  background: red;
  height: 2rem;
}

.menu-item {
  position: relative;
  padding: 1rem;
  background: yellow;
  cursor: pointer;
}

.submenu {
  display: none; /* changes to 'block' with CSS */
  padding: 1rem;
  background: lightblue;
  position: absolute;
  top: 4rem;
  left: 0;
  width: 6rem;
}

.menu-item:focus > .submenu {
  display: block;
}
<header>
  <div id="item-1" class="menu-item menu-item-1" tabindex="-1">ITEM 1
    <div id="sub-item-1" class="submenu submenu-1">SUB-ITEM-1</div>
  </div>
  <div id="item-2" class="menu-item menu-item-2" tabindex="-1">ITEM 2
    <div id="sub-item-2" class="submenu submenu-2">SUB-ITEM-2</div>
  </div>
</header>

The main drawback to this is that we're using :focus, meaning that if you click on a menu-item again, it will remain focused rather than bluring, which as a result will keep the menu item in-view rather than hiding it. The below approaches that use JS handle this case though:

Adding an event-listener to the document:

Another possible solution is to update your JS. This involves selecting all menu items and submenu items using querySelectorAll(). You can then add event listeners to your menu-items by looing through the NodeList returned by the call to .querySelectorAll(). When you click on a menu-item, you can grab its associated submenu item using .querySelector() on the current menuItem. In order to hide the items when you click elsewhere on the screen, you can listen for click events on the document by adding an event listener to that, and hide your submenu items accordingly. Within your event listeners that you add to your menu items, you can call .stopPropagation() to prevent the click event on the menu items from bubbling up to the document and causing the document event-listener to execute (and hide all items).

const menuItems = document.querySelectorAll(".menu-item"); // Get all menu items in an array-like structure (NodeList)
const submenuItems = document.querySelectorAll(".submenu"); // select all submenu items

const hideMenus = (menus, ignore) => menus.forEach(menu => { // loop through all items (use: [...menus].forEach((menu) => {) for better browser support)
  if (menu !== ignore) // if we encounter an element that we want to keep visible, skip it, otherwise, remove its visibility
    menu.classList.remove("visible");
});

menuItems.forEach(menuItem => { // loop through the NodeList menu items
  menuItem.addEventListener("click", (e) => {
    e.stopPropagation(); // stop event from bubbling up to the document and executing the below `document.addEventListener()`  when menu item is clicked
    if (e.target === menuItem) { // don't hide when we click on a sub-menu-item (e.target = child sub-menu-item if that is clicked)
      const thisSubmenu = menuItem.querySelector(".submenu");
      thisSubmenu.classList.toggle('visible'); // toggle visibility of submenu under our item
      hideMenus(submenuItems, thisSubmenu); // hide all other submenus
    }
  });
});

document.addEventListener("click", (e) => {
  hideMenus(submenuItems);
});
body {
  display: flex;
  justify-content: center;
  margin: 0;
  height: 100vh;
  width: 100%;
}

header {
  margin-top: 2rem;
  display: flex;
  width: 50%;
  justify-content: space-evenly;
  align-items: center;
  padding: 1rem;
  background: red;
  height: 2rem;
}

.menu-item {
  position: relative;
  padding: 1rem;
  background: yellow;
  cursor: pointer;
}

.submenu {
  display: none;
  /* changes to 'block' with javascript */
  padding: 1rem;
  background: lightblue;
  position: absolute;
  top: 4rem;
  left: 0;
  width: 6rem;
}

.submenu.visible {
  display: block;
}
<header>
  <div id="item-1" class="menu-item menu-item-1">ITEM 1
    <div id="sub-item-1" class="submenu submenu-1">SUB-ITEM-1</div>
  </div>
  <div id="item-2" class="menu-item menu-item-2">ITEM 2
    <div id="sub-item-2" class="submenu submenu-2">SUB-ITEM-2</div>
  </div>
</header>

Using event delegation:

You can update the above example to use event delegation, which allows you to only use one event listener on the document rather than adding one per menu item (thus helping limit the resources used by your browser). You can then use e.target and .closest() to determine what element you clicked on (see code comments for details):

const submenuItems = document.querySelectorAll(".submenu"); // select all submenu items

const hideMenus = (menus, ignore) => menus.forEach(menu => { // loop through all items (use: [...menus].forEach((menu) => {) for better browser support)
  if(menu !== ignore) // if we encounter an element that we want to keep visible, skip it, otherwise, remove its visibility
    menu.classList.remove("visible");
});

document.addEventListener("click", (e) => {
  const clickedItem = e.target, menuItem = clickedItem.closest(".menu-item");
  //       v-- use `= menuItem && menuItem.querySelector(...)` for better browser support
  const thisSubmenu = menuItem?.querySelector(".submenu"); // grab the submenu from the menuItem we clicked on (or parent menuItem if we clicked on a submenu item)
  
  if(clickedItem === menuItem) // we clicked on a menu-item
    thisSubmenu.classList.toggle('visible'); // toggle visibility of submenu under our menu-item
    
  hideMenus(submenuItems, thisSubmenu);    
});
body {
  display: flex;
  justify-content: center;
  margin: 0;
  height: 100vh;
  width: 100%;
}

header {
  margin-top: 2rem;
  display: flex;
  width: 50%;
  justify-content: space-evenly;
  align-items: center;
  padding: 1rem;
  background: red;
  height: 2rem;
}

.menu-item {
  position: relative;
  padding: 1rem;
  background: yellow;
  cursor: pointer;
}

.submenu {
  display: none; /* changes to 'block' with javascript */
  padding: 1rem;
  background: lightblue;
  position: absolute;
  top: 4rem;
  left: 0;
  width: 6rem;
}

.submenu.visible {
  display:block;
}
<header>
  <div id="item-1" class="menu-item menu-item-1">ITEM 1
    <div id="sub-item-1" class="submenu submenu-1">SUB-ITEM-1</div>
  </div>
  <div id="item-2" class="menu-item menu-item-2">ITEM 2
    <div id="sub-item-2" class="submenu submenu-2">SUB-ITEM-2</div>
  </div>
</header>

One way to solve this would be to take advantage of focus and blur events. div elements do not receive focus by default, but we can add the tabindex attribute to fix that.

When you click the div it becomes focused, so we simply listen for a blur event and hide the div.

var menu_item_1 = document.getElementById('item-1'),
    menu_item_2 = document.getElementById('item-2'),
    sub_menu_item_1 = document.getElementById('sub-item-1'),
    sub_menu_item_2 = document.getElementById('sub-item-2')

if (menu_item_1) {
      menu_item_1.addEventListener('click', function(e){
        sub_menu_item_1.classList.toggle('visible')

       // hide submenu 2
        sub_menu_item_2.classList.remove('visible')
    }, false)
}

if (menu_item_2) {
      menu_item_2.addEventListener('click', function(e){
        sub_menu_item_2.classList.toggle('visible')

        // hide submenu 1
        sub_menu_item_1.classList.remove('visible')
    }, false)
}

// listen for blur events
 menu_item_1.addEventListener('blur', function(e){  sub_menu_item_1.classList.remove('visible')
})

 menu_item_2.addEventListener('blur', function(e){  sub_menu_item_2.classList.remove('visible')
})
body {
  display: flex;
  justify-content: center;
  margin: 0;
  height: 100vh;
  width: 100%;
}

header {
  margin-top: 2rem;
  display: flex;
  width: 50%;
  justify-content: space-evenly;
  align-items: center;
  padding: 1rem;
  background: red;
  height: 2rem;
}

.menu-item {
  position: relative;
  padding: 1rem;
  background: yellow;
  cursor: pointer;
}

.submenu {
  display: none; /* changes to 'block' with javascript */
  padding: 1rem;
  background: lightblue;
  position: absolute;
  top: 4rem;
  left: 0;
  width: 6rem;
}

.submenu.visible {
  display:block;
}
<header>
  <div id="item-1" class="menu-item menu-item-1" tabindex="-1">ITEM 1
    <div id="sub-item-1" class="submenu submenu-1" tabindex="-1">SUB-ITEM-1</div>
  </div>
  <div id="item-2" class="menu-item menu-item-2" tabindex="-1">ITEM 2
    <div id="sub-item-2" class="submenu submenu-2" tabindex="-1">SUB-ITEM-2</div>
  </div>
</header>

I also added console.log on each submenu to make sure they are interactive before the menu closes.

const menus = Array.from(document.querySelectorAll('.menu-item'));

function handleOnClickOutsideMenu(e) {
  const target = menus.filter(menu => menu.contains(e.target));
  if (target.length) {
     // user is clicking inside a menu: don't do anything.
     // this is handled by handleOnMenuToggle.
     return;
  }
  // close all the menus in the page
  menus.forEach(menu => menu.classList.remove('expanded'));
  // we don't need it anymore (it is added dynamically in the handleOnMenuToggle)
  document.removeEventListener('click', handleOnClickOutsideMenu);  
}

function handleOnMenuToggle(e) {
  // close other menus
  menus
    .filter(menu => menu !== e.currentTarget)
    .forEach(menu => menu.classList.remove('expanded'));
  // toggle current menu
  e.currentTarget.classList.toggle('expanded');

  // Important optimization:
  // we want the click event on the document only when a menu is expanded
  if (e.currentTarget.classList.contains('expanded')) {       
    document.addEventListener('click', handleOnClickOutsideMenu);
  } else {
    document.removeEventListener('click', handleOnClickOutsideMenu);     
  }
}

menus.forEach(menu => {
  menu.addEventListener('click', handleOnMenuToggle); 
});
body {
  display: flex;
  justify-content: center;
  margin: 0;
  height: 100vh;
  width: 100%;
}

header {
  margin-top: 2rem;
  display: flex;
  width: 50%;
  justify-content: space-evenly;
  align-items: center;
  padding: 1rem;
  background: red;
  height: 2rem;
}

.menu-item {
  position: relative;
  padding: 1rem;
  background: yellow;
  cursor: pointer;
}

.submenu {
  display: none;
  padding: 1rem;
  background: lightblue;
  position: absolute;
  top: 4rem;
  left: 0;
  width: 6rem;
}

/* adding .expanded on menu-item so it can handle multiple sub menu */
.menu-item.expanded .submenu {
  display: block;     
}
<header>
  <div id="item-1" class="menu-item menu-item-1">ITEM 1
    <div id="sub-item-1" class="submenu submenu-1" onclick="console.log(this)">SUB-ITEM-1</div>
  </div>
  <div id="item-2" class="menu-item menu-item-2">ITEM 2
    <div id="sub-item-2" class="submenu submenu-2" onclick="console.log(this)">SUB-ITEM-2</div>
  </div>
</header>

Related