Hotwire Discussion

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>

3 Likes

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()
  }
}
3 Likes

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

I’m trying to replicate an old and convoluted hamburger menu with overlay elements and whatnot, and I’ve managed to strip it way down, thanks to the useClickOutside part of Stimulus use (thanks, @adrienpoly !). But one cool thing about the old convoluted markup/css was the fact that the invisible overlay element used as a click-outside target made it easy to style it with a pointer cursor.

Can I replicate this with useClickOutside without extra divs?

I already toggle a class on the body element that indicates the menu is open, so I tapped into that existing class with cursor: pointer;, but it feels wrong to add a pointer to the entire body, even if it’s just for a modal that will stay closed 95 percent of the time. Is there a better way?

Any recommendations for this same pattern concerning a collection of items? Here’s an example of the UX behaviors I’m looking to emulate.

<ul>
  <li data-controller="editor" data-action="click@window->editor#hideButton">
    <form>
                  <label for="lname">Item1</label><br>
      <input type="text" data-action="click->editor#showButton">
      <input class="hide" type="submit" value="Submit" data-editor-target="button">
    </form>
  </li>
  <li data-controller="editor" data-action="click@window->editor#hideButton">
    <form>
                  <label for="lname">Item2</label><br>
      <input type="text" data-action="click->editor#showButton">
      <input class="hide" type="submit" value="Submit" data-editor-target="button">
    </form>
  </li>
  <li data-controller="editor" data-action="click@window->editor#hideButton">
    <form>
                  <label for="lname">Item4</label><br>
      <input type="text" data-action="click->editor#showButton">
      <input class="hide" type="submit" value="Submit" data-editor-target="button">
    </form>
  </li>
</ul>
const application = Stimulus.Application.start()

application.register("editor", class extends Stimulus.Controller {
  static targets = ["button"]
  showButton(){
   this.buttonTarget.classList.remove("hide") 
  }
  hideButton(){
      if(this.element === event.target || this.element.contains(event.target)) return;

   this.buttonTarget.classList.add("hide") 
    console.log("clicked")
  }  
})

Calling this globally doesn’t feel right – especially when there are other interactions on the page for the user. Realize something may be off about this entire approach.