Stimulus Discourse

Best practices for handling clicks outside element

I often find myself wanting to execute a particular action when the user clicks outside of an element. For example when dismissing a modal window.

Right now I use something along these lines:

<!-- gets run on ANY click ANYWHERE -->
<div data-action="click@window->myController#myActionIfOutsideClick"></div>


// Stimulus action
myActionIfOutsideClick(event) {
  event.preventDefault()

  // Ignore event if clicked within element
  if(this.element === event.target || this.element.contains(event.target)) return;

  // Execute the actual action we're interested in
  this.myAction()
}

This works, but tightly couples the action to the event. Which I think is against the spirit of Stimulus?

Anytime I want to handle the “click outside element” event, I need to make sure I have an additional action that checks the user isn’t clicking on the element itself.

I wonder how others approach this and whether it’s worth considering a custom “clicked outside element” event to the Stimulus library? So we could do something like this instead:

<div data-action="click-outside@window->myController#myAction"></div>

1 Like

I think your solution is a good one.

Thanks for raising this question I used it as an excuse to add a new behavior to stimulus-use to handle similar cases.
This approach is slightly different as it removes the markup requirements from your HTML.

1 Like

Hello there!

I use the same approach for my dropdowns (button + dropdown popup).

HTML:

<div data-controller="dropdown">
    <button 
        data-action="click->dropdown#toggle click@window->dropdown#hide touchend@window->dropdown#hide"
        data-target="dropdown.button"
        aria-haspopup="true"
        aria-expanded="false"
    >
        Open dropdown popup
    </button>
    <div class="is-hidden" data-target="dropdown.popup">
        Dropdown popup's content
    </div>
</div>

JS:

import { Controller } from 'stimulus';

export default class extends Controller {
    static targets = [ 'button', 'popup' ]

    toggle(event) {
        event.preventDefault();

        if (this.buttonTarget.getAttribute('aria-expanded') == "false") {
            this.show();
        } else {
            this.hide(null);
        }
    }

    show() {
        this.buttonTarget.setAttribute('aria-expanded', 'true');
        this.buttonTarget.classList.add('is-active');
        this.popupTarget.classList.remove('is-hidden');
    }

    hide(event) {
        if (event && (this.popupTarget.contains(event.target) || this.buttonTarget.contains(event.target))) {
            // event.preventDefault(); // I don't remeber why I did it, but i need this line to be commented
            return;
        }

        this.buttonTarget.setAttribute('aria-expanded', 'false');
        this.buttonTarget.classList.remove('is-active');
        this.popupTarget.classList.add('is-hidden');
    }
}

I suggest you to add an addition event listener touchend@window->dropdown#hide to catch outside clicks on some mobile devices.

2 Likes

Thanks Adrien. This is really helpful. I appreciate you spending your time on this.

If I understand your approach correctly, this would require us to use an action called clickOutside, right?

That means we’ll be declaring all behavior in the clickOutside action, rather than a data-action attribute which is typically the case in Stimulus. (e.g. data-target="clickOutside->modal#close")

It does seem very pragmatic though. I tried building a custom “clickOutside” event so we could bind the target/action as above, but I couldn’t get it to work.

hey @marckohlbrugge

FYI I released a new version of Stimulus Use and you now have a click outside mixin available

import { Controller } from 'stimulus'
import { useClickOutside } from 'stimulus-use'

export default class extends Controller {

  connect() {
    useClickOutside(this)
  }

  clickOutside(event) {
    // example to close a modal
    event.preventDefault()
    this.modal.close()
  }
}
1 Like

Awesome! Thanks for sharing. The custom event is exactly what I was looking for.