DEV Community

Jamie Reynolds
Jamie Reynolds

Posted on

Clicking outside the box - Making your Vue app aware of events outside its world

Often I have created Vue apps that are not an SPA but part of an existing page. The page loads up, the app is injected and all is well. But recently I encountered an issue where the app needed to know when it wasn't the centre of attention anymore. More specifically, when the user was interacting with the page outside the app it needed to change its state.

This app was a search component which contained fields which expanded to display complex selection options. However, these obscured other parts of the form and indeed the page while in open. This is fine while being interacted with but expected behaviour when another part of the page is clicked or tapped - would see the expanded selection options hidden again.

The app is outlined red here. Clicks outside go unnoticed by the app
Diagram of app and page structure

So how do we do this? I could not use the focus of the input as this was lost while making a selection. I needed to detect an event outside the app. There are a few packages developed by the Vue Community (vue-clickaway,
v-click-outside) but this seemed like something that could be solved without adding another dependancy. In this instance we also planned to rebuild the page entirely in Vue later so wanted something light touch that could be easily removed later.

Essentially, we want to add a listener to the document that the Vue app can listen for. To do this we will use a Vue Custom Directive

There are a couple of ways to create a Custom Directive in Vue.
Here, we will register it locally on a component - which we will do on our app.vue. This way we can place it alongside a method that we want to call, emitting an event whenever the directive detects a click. We can then listen to this event in any of the components that need to close themselves.

  name: 'App',
  directives: {
    'click-outside-app': {
      // Directive content that will call the defocusApp method below
    },
  },
  methods: {
    defocusApp() {
      this.$root.$emit('defocusApp'); // emitted event
    },
  },
Enter fullscreen mode Exit fullscreen mode

So inside our custom directive we use the bind method to add an event listener to the page that detects clicks that are not on (or a child of) the component that is using the directive (app.vue).

directives: {
    "click-outside-app": {
      bind: function(el, binding) {
        // Define ourClickEventHandler
        const ourClickEventHandler = event => {
          if (!el.contains(event.target) && el !== event.target) {
            // as we are attaching an click event listern to the document (below)
            // ensure the events target is outside the element or a child of it
            binding.value(event); // before binding it
          }
        };
        // attached the handler to the element so we can remove it later easily
        el.__vueClickEventHandler__ = ourClickEventHandler;

        // attaching ourClickEventHandler to a listener on the document here
        document.addEventListener("click", ourClickEventHandler);
      },
      unbind: function(el) {
        // Remove Event Listener
        document.removeEventListener("click", el.__vueClickEventHandler__);
      }
    }
  },
Enter fullscreen mode Exit fullscreen mode

For completeness, we also use the unbind event to remove the event listener - should the component be removed.

Now the directive is created we can use it on the app element like so.

<div id="app" v-click-outside-app="defocusApp">
    <someChildComponent />
  </div>
Enter fullscreen mode Exit fullscreen mode

If you used your Vue developer extension you would see our defocusApp event firing on when you click anywhere on the page - outside the app! Now we need to do something from within our components on hearing that event.

Because we want all of our components to do the same thing when the user clicks outside the app (close their input dialog), it made sense to use a mixin that can be included in each of the components. This will, on the created lifecycle of those components, bind an event that calls a method on each components using it. In our case a closeDialogues() method that sets a commonly named data property to false.

appFocusHandlerMixin.js

export default {
  created() {
    this.$root.$on("defocusApp", this.closeDialogues);
  },
  methods: {
    closeDialogues() {
      this.isDialbogueOpen = false;
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

Use the mixin with in any component that need to listen for a click outside the app, adding the the common data element that will be set false.

mixins: [appFocusHandler],
  data() {
    return {
      isDialbogueOpen: true
    };
  }
Enter fullscreen mode Exit fullscreen mode

Im pretty sure this could be cleaned up or extended to mulitple methods but this seems to fit the bill for my use case. Please use the comments below to offer suggestions. I've created a Codesandbox with a working example.

Top comments (2)

Collapse
 
feelhippo profile image
FeelHippo

Wonderful, thank you!
In Vue3 API for custom directives has changed slightly, so in order to make this work you should rename the "bind" hook to "beforeMount" and "bind" to "unmounted". Otherwise, { emit } can be passed in the new setup() composition API but I think that would be overkill.

Collapse
 
pconnolly88 profile image
Paul Connolly

Cool! Thanks for the info.