Dropdown focusout not working on descendant elements as expected

1.6k views Asked by At

I'm aware this is a common question but none of the answers I've seen solve my problem, apologies if I have missed one and this can be removed/marked as duplicate obviously...

Markup

<div class="has-dropdown">
    <button class="js-dropdown-trigger">
        Dropdown
    </button>

    <div class="dropdown">
        <div class="dropdown__item">
            Some random text with a <a href="#" class="stop-propagation">link</a> in it.
        </div>
        <div class="dropdown__divider"></div>
        <div class="dropdown__item">
            <a href="#">Item One</a>
        </div>
        <div class="dropdown__item">
            <a href="#">Item Two</a>
        </div>
        <div class="dropdown__item">
            <a href="#">Item Three</a>
        </div>
    </div>
</div>

Script

$('.has-dropdown').off().on('click', '.js-dropdown-trigger', (event) => {
    const $dropdown = $(event.currentTarget).next('.dropdown');

    if (!$dropdown.hasClass('is-active')) {
        $dropdown.addClass('is-active');
    } else {
        $dropdown.removeClass('is-active');
    }
});

$('.has-dropdown').on('focusout', (event) => {
    const $dropdown = $(event.currentTarget).children('.dropdown');

    $dropdown.removeClass('is-active');
});

Styling

.has-dropdown {
    display: inline-flex;
    position: relative;
}

.dropdown {
    background-color: #eee;
    border: 1px solid #999;
    display: none;
    flex-direction: column;
    position: absolute;
    top: 100%;
    left: 0;
    width: 300px;
    margin-top: 5px;
}

.dropdown.is-active {
    display: flex;
}

.dropdown__item {
    padding: 10px;
}

.dropdown__divider {
    border-bottom: 1px solid #999;
}

Fiddle

http://jsfiddle.net/joemottershaw/3yzadmek/

It's unbelievably simple, clicking the js-dropdown-trigger toggles the is-active dropdown class fine, clicking outside the has-dropdown container removes the is-active dropdown class too.

Except, what I expected to happen is focusing on a descendant element (either click or tab) of the has-dropdown element would mean that the focusout event handler shouldn't be triggered as you are still focused on a descendant element of the has-dropdown container.

The focusout event is sent to an element when it, or any element inside of it, loses focus. This is distinct from the blur event in that it supports detecting the loss of focus on descendant elements

I know I could remove the focusout event handler and use something like:

$(document).on('click', (event) =>{
    const $dropdownContainer = $('.has-dropdown');

    if (!$dropdownContainer.is(event.target) && $dropdownContainer.has(event.target).length === 0) {
        $dropdownContainer.find('.dropdown').removeClass('is-active');
    }
});

This works, but if you were to click on the trigger and then tab through the links, when you tab past the last link, the dropdown will still be visible. Just struggling to find the best solution to keep the accessibility side of things.

I want to stick to the focusout method if at all possible.

Updated based on darshanags answer

Although the updated script works for single elements, adding other elements to the body causes focusout not to work as intended anymore. I think this is because of the if statement seems to be true even when focus is applied to any element after the has-dropdown container, not just descendants? Cause if you are to update the HTML and add more focusable elements such as an input after the dropdown. When tabbing from the last focusable element from within the has-dropdown container to the input, the dropdown stays active. It only works if the dropdown is the last element in the DOM and only triggers when focus is lost on the DOM entirely.

1

There are 1 answers

10
darshanags On BEST ANSWER

You're almost there with your code - but I believe that there needs to be a bit more clarity of how focusout works with natively non-focusable elements (i.e: div, p) and their descendants which could be focusable elements (inputs, anchors).

When a container is bound to the focusout event which contains focusable elements, it's focusout event is triggered each time any of it's focusable child elements loses focus - this might be by keyboard navigation or by clicking on another child element or on the container itself. I've setup a fiddle that demonstrates this: https://jsfiddle.net/darshanags/v5gk2cz8/ - console messages are sent out each time the container gains or loses focus.

Q: So, why does the menu hide itself in the example given in the question?

A: The menu becomes visible when you click on the button, clicking on the button makes the button gain focus on itself. But once you click on the menu or an anchor inside the menu element, the button loses focus - this in turn triggers the 'focusout' event of the parent element and causes the menu to hide itself. This happens because the focusout event supports event bubbling.

Q: How do we get around this?

A1.1: We make the parent element focusable by giving it a tabindex:

<div class="has-dropdown" tabindex="0">

This addresses a couple of important things:

  1. Gives the div element a focus outline - this in turn enhances accessibility of the element.
  2. Helps click events register focus-related events on the div element - in this example, that would focusout. If the tabindex was omitted we would need to add additional JavaScript to compensate for the lost functionality.

A1.2: Within the focusout event handler, we check if the element which gains focus is a descendant of the parent element, if it is, we don't remove the is-active class from the menu.

Complete example code:

$('.has-dropdown').off().on('click', '.js-dropdown-trigger', (event) => {
  const $dropdown = $(event.currentTarget).next('.dropdown');

  if (!$dropdown.hasClass('is-active')) {
    $dropdown.addClass('is-active');
  } else {
    $dropdown.removeClass('is-active');
  }
});

$('.has-dropdown').on('focusout', function(event) {
  const $reltarget = $(event.relatedTarget);
  const $currenttarget = $(event.currentTarget);
  const $dropdown = $currenttarget.children('.dropdown');

  // remove 'is-active' class only if the element
  // that is gaining focus is not a child of the parent.
  // parent = div.has-dropdown
  if (!$reltarget.closest('.has-dropdown').is($currenttarget)) {
    $dropdown.removeClass('is-active');
  }

});
.has-dropdown {
  display: inline-flex;
  position: relative;
}

.dropdown {
  background-color: #eee;
  border: 1px solid #999;
  display: none;
  flex-direction: column;
  position: absolute;
  top: 100%;
  left: 0;
  width: 300px;
  margin-top: 5px;
}

.dropdown.is-active {
  display: flex;
}

.dropdown__item {
  padding: 10px;
}

.dropdown__divider {
  border-bottom: 1px solid #999;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<div class="has-dropdown" tabindex="0">
  <button class="js-dropdown-trigger">
        Dropdown
    </button>

  <div class="dropdown">
    <div class="dropdown__item">
      Some random text with a <a href="#" class="stop-propagation">link</a> in it.
    </div>
    <div class="dropdown__divider"></div>
    <div class="dropdown__item">
      <a href="#">Item One</a>
    </div>
    <div class="dropdown__item">
      <a href="#">Item Two</a>
    </div>
    <div class="dropdown__item">
      <a href="#">Item Three</a>
    </div>
  </div>
</div>

<div class="has-dropdown" tabindex="0">
  <button class="js-dropdown-trigger">
        Dropdown
    </button>

  <div class="dropdown">
    <div class="dropdown__item">
      Some random text with a <a href="#" class="stop-propagation">link</a> in it.
    </div>
    <div class="dropdown__divider"></div>
    <div class="dropdown__item">
      <a href="#">Item One</a>
    </div>
    <div class="dropdown__item">
      <a href="#">Item Two</a>
    </div>
    <div class="dropdown__item">
      <a href="#">Item Three</a>
    </div>
  </div>
</div>

<input name="tf" type="text"/>

I've forked the original fiddle and modified it to show this in action. You may find the modified fiddle here: http://jsfiddle.net/darshanags/60jnusvk/.

Other helpful information:

I use event.relatedTarget to determine the element which gains focus every time the focusout event is triggered. More information on event.relatedTarget can be found here: https://api.jquery.com/event.relatedTarget/.

Update

I've refactored some of my original code, it's much simpler now: http://jsfiddle.net/darshanags/60jnusvk/24/. I will leave this fiddle and the original for reference.