Search code examples
javascriptvue.jsvuejs3addeventlistenervue-directives

Directives for Detecting Clicks outside Element


Based on this Article https://medium.com/@Taha_Shashtari/an-easy-way-to-detect-clicks-outside-an-element-in-vue-1b51d43ff634 i implemented the same methodology of the directive for detecting outside element click, at first i had to change things as vue 2 directives have been changed in vue 3, but i got so far that:

  1. When i click the Icon to Toggle the Box -> The box is shown
  2. When i click outside the Box -> The box is toggled

The only thing that isn't working is when i click inside the box itself it gets toggled again, which isnt suppose to happen.

Code

Directive:

let handleOutsideClick;
const closable = {
  beforeMount(el, binding, vnode) {
    handleOutsideClick = (e) => {
      e.stopPropagation();
      const { handler, exclude } = binding.value;

      let clickedOnExcludedEl = false;
      exclude.forEach((id) => {
        if (!clickedOnExcludedEl) {
          const excludedEl = document.getElementById(id);
          clickedOnExcludedEl = excludedEl.contains(e.target);
        }
      });

      if (!el.contains(e.target) && !clickedOnExcludedEl) {
        binding.instance[handler]();
      }
    };
    document.addEventListener("click", handleOutsideClick);
    document.addEventListener("touchstart", handleOutsideClick);
  },
  afterMount() {
    document.removeEventListener("click", handleOutsideClick);
    document.removeEventListener("touchstart", handleOutsideClick);
  },
};

export default closable;

PS: I changed the usage of refs into IDs

CartIcon:

<template>
  <div
    id="checkoutBoxHandler"
    ref="checkoutBoxHandler"
    @click="showPopup = !showPopup"
    class="cart-icon"
  >
    <font-awesome-icon icon="fa-solid fa-cart-shopping" />
    <span id="cart-summary-item">{{ cartItemsCount }}</span>
    <div
      v-show="showPopup"
      v-closable='{
        exclude: ["checkoutBox","checkoutBoxHandler"],
        handler: "onClose",
      }'
      id="checkoutBox"
    >
      <CheckOutBox   v-if="this.userCart" :userCart="this.userCart"></CheckOutBox>
    </div>
  </div>
</template>

onClose handler:

 onClose() {
      this.showPopup = false;
    },

Can anyone see what i might be doing wrong here or maybe missing?

Thanks in advance

EDIT after Turtle Answers:

This is the Code i m using:

Directive:

const clickedOutsideDirective = {
  mounted(element, binding) {
    
    const clickEventHandler = (event) => {
      event.stopPropagation();
      console.log(element.contains(event.target))//True on click on the box
      if (!element.contains(event.target)) {
        binding.value(event)
      }
    }
    element.__clickedOutsideHandler__ = clickEventHandler
    document.addEventListener("click", clickEventHandler)
  },
  unmounted(element) {
    document.removeEventListener("click", element.__clickedOutsideHandler__)
  },
}

export default clickedOutsideDirective

Component:

<div
    id="checkoutBoxHandler"
    ref="checkoutBoxHandler"
    @click="showPopup = !showPopup"
    v-closable='onClose'
    class="cart-icon"
  >
    <font-awesome-icon icon="fa-solid fa-cart-shopping" />
    <span id="cart-summary-item">{{ cartItemsCount }}</span>
    <div
      v-show="showPopup"
      
      ref="checkoutBox"
      id="checkoutBox"
    >
      <CheckOutBox :userCart="this.userCart"></CheckOutBox>
    </div>
  </div>

The box is being displayed but on click on the box it still disappear


Solution

  • It looks like the problem could be multiple registered event listeners.

    afterMount should be unmounted. If fixing that isn't enough, you may need to ensure you're unregistering the event correctly. You can store the handler on the element like this:

    const closable = {
      beforeMount(el, binding, vnode) {
        el.__handleOutsideClick__ = (e) => {
          e.stopPropagation();
          const { handler, exclude } = binding.value;
    
          let clickedOnExcludedEl = false;
          exclude.forEach((id) => {
            if (!clickedOnExcludedEl) {
              const excludedEl = document.getElementById(id);
              clickedOnExcludedEl = excludedEl.contains(e.target);
            }
          });
    
          if (!el.contains(e.target) && !clickedOnExcludedEl) {
            binding.instance[handler]();
          }
        };
        document.addEventListener("click", el.__handleOutsideClick__);
        document.addEventListener("touchstart", el.__handleOutsideClick__);
      },
      // The correct lifecycle method is 'unmounted'
      unmounted(el) {
        document.removeEventListener("click", el.__handleOutsideClick__);
        document.removeEventListener("touchstart", el.__handleOutsideClick__);
      },
    };
    
    export default closable;
    
    

    Other advice

    • Don't call stopPropagation on the event, because it could swallow clicks on other UI elements.
    • Forward the event when invoking the handler so that the handler can inspect it.

    To ensure your directive doesn't break, you probably don't want to reference the excluded nodes by ID, but rather by ref as in the article you linked.

    Or, drop the exclusions feature altogether. Without it, your directive can look like below. It looks like you're only using it to exclude things that are already inside your popup. In my experience, clicked outside should mean clicked outside. If there are additional considerations, I would prefer to let the handler take care of them by inspecting the returned event.

    import { Directive } from 'vue'
    
    // Trigger a function when a click is registered outside the element
    const clickedOutsideDirective = {
      mounted(element, binding) {
        const clickEventHandler = (event) => {
          if (!element.contains(event.target)) {
            binding.value(event)
          }
        }
        element.__clickedOutsideHandler__ = clickEventHandler
        document.addEventListener("click", clickEventHandler)
      },
      unmounted(element) {
        document.removeEventListener("click", element.__clickedOutsideHandler__)
      },
    }
    
    export default clickedOutsideDirective
    

    Now the usage looks like this

    <template>
      <div
        id="checkoutBoxHandler"
        ref="checkoutBoxHandler"
        @click="showPopup = !showPopup"
        class="cart-icon"
      >
        <font-awesome-icon icon="fa-solid fa-cart-shopping" />
        <span id="cart-summary-item">{{ cartItemsCount }}</span>
        <div
          v-show="showPopup"
          v-clicked-outside='onClose'
          id="checkoutBox"
        >
          <CheckOutBox   v-if="this.userCart" :userCart="this.userCart"></CheckOutBox>
        </div>
      </div>
    </template>