Composing Reusable Modal Dialogs with Vue.js

I was working on an app idea with Vue.js and Laravel the other day and needed to create some modal forms that were submitted asynchronously.

The example on the Vue.js website is a great starting point, but it breaks down when two modals need different behavior.

A starting point

Let's say we're building a blog (how creative) and we want a modal form for creating a new post.

Playing off of the example above, we might start with something like this:

It's a good start, but there's a few things missing that I'd like to add:

  1. Close the modal when clicking on the background.
  2. Close the modal when the escape key is pressed.
  3. Reset the form data when the modal is closed.

Closing when the background is clicked

The easiest way to do this is to bind a click handler to the background that closes the form:

<!-- Template for the modal component -->
<div class="modal-mask" @click="close" v-show="show" transition="modal">
    <div class="modal-container">
        <!-- ... -->
    </div>
</div>
// The Vue modal component
Vue.component('modal', {
    // ...
    methods: {
        // ...
        close: function () {
            this.show = false;
        }
    }
});

This gets us close but you'll notice that if you click inside the modal, the modal still closes because the click event is bubbling up to the .modal-mask element.

We can fix this by using @click.stop on the modal itself to prevent click events from propagating to the parent:

<!-- Template for the modal component -->
<div class="modal-mask" @click="close" v-show="show" transition="modal">
    <div class="modal-container" @click.stop>
        <!-- ... -->
    </div>
</div>

Here's what we've got now:

Closing when escape is pressed

Closing on escape is a little trickier, since we have to listen for key presses on the entire document.

The best I've come up with so far is to add an event listener to the document manually:

// The Vue modal component
Vue.component('modal', {
  // ...
  ready: function () {
    document.addEventListener("keydown", (e) => {
      if (this.show && e.keyCode == 27) {
        this.close();
      }
    });
  }
});

If the escape key is pressed, we check to see if the modal is currently open, and if so, close it.

Here's what that gives us:

Resetting the form when the modal is closed

You'll notice right now that if you open the modal, fill out the fields, and then close the modal, the fields are still filled out when you open it again.

We can solve that by adding some v-model bindings to our form fields, and clearing them when we close the form:

<!-- Template for the modal component -->
<!-- ... -->
    <div class="modal-body">
        <label class="form-label">
            Title
            <input v-model="title" class="form-control">
        </label>
        <label class="form-label">
            Body
            <textarea v-model="body" rows="5" class="form-control"></textarea>
        </label>
    </div>
<!-- ... -->
// The Vue modal component
Vue.component('modal', {
  // ...
  data: function () {
    return {
      title: '',
      body: ''
    };
  },
  methods: {
    // ...
    close: function () {
      this.show = false;
      this.title = '';
      this.body = '';
    },
  },
  // ...
});

That leaves us with this:

Making the modal reusable

Say we needed another modal form for commenting on a post.

Right now that would mean duplicating the entire component, including a lot of standard modal behavior (like closing when the escape key is pressed or the background is clicked), because we've built our form and modal as one component.

We need to somehow extract the pieces that are going to be the same for every modal. So what's going to stay the same for each modal, and what might change?

Things that will stay the same:

  • Popup behavior
  • Closing when pressing escape
  • Closing when clicking the background
  • Performing some sort of reset when it closes

Things that will change:

  • The form fields themselves
  • Any necessary validation
  • The endpoint that the form is going to submit to

One approach to this would be to setup a complex combination of custom props and slots on the modal and try to make the entire thing configurable.

This is a bad idea.

A better solution is to think of the contents of the modal as a completely separate, swappable component.

Extracting a modal component

How we pull this off might seem a little counter-intuitive.

Instead of thinking of one modal where we can swap the contents, think of the new post and new comment modals as their own components that each contain a base modal as their "shell".

A little confusing, I know, but hang in there with me.

First, let's acknowledge that our current modal is really a NewPostModal, and update the name in a few places:

<!-- Template for the NewPostModal component -->
<script type="x/template" id="new-post-modal-template">
    <div class="modal-mask" @click="close" v-show="show" transition="modal">
        <!-- ... -->
    </div>
</script>
<!-- The main app template -->
<div id="app">
    <new-post-modal :show.sync="showModal"></new-post-modal>
    <button id="show-modal" @click="showModal = true">New Post</button>
</div>
// The Vue NewPostModal component
Vue.component('NewPostModal', {
  template: '#new-post-modal-template',
  // ...
});

Extracting the template

Next, let's start moving the reusable pieces to a new component.

The .modal-mask and .modal-container elements represent the shell that we want to reuse, so let's move those into the new component, and add a <slot> to render our form:

<!-- Template for the shell Modal component -->
<script type="x/template" id="modal-template">
    <div class="modal-mask" @click="close" v-show="show" transition="modal">
        <div class="modal-container" @click.stop>
            <slot></slot>
        </div>
    </div>
</script>

Here's the updated template for the NewPostModal, now making use of our Modal component as it's shell:

<!-- Template for the NewPostModal component -->
<script type="x/template" id="new-post-modal-template">
    <modal v-show="show">
        <div class="modal-header">
            <h3>New Post</h3>
        </div>

        <div class="modal-body">
            <!-- Form fields removed for brevity -->
        </div>

        <div class="modal-footer text-right">
            <button class="modal-default-button" @click="savePost()">
                Save
            </button>
        </div>
    </modal>
</script>

Extracting the modal behavior

Right now pressing escape still works to close the modal, but clicking on the background is broken.

Let's move both of these pieces out of the NewPostModal component and into our reusable Modal component.

First let's move the ready function that binds the key listener:

// The Vue Modal component
Vue.component('Modal', {
  template: '#modal-template',
  ready: function () {
    document.addEventListener("keydown", (e) => {
      if (this.show && e.keyCode == 27) {
        this.close();
      }
    });
  }
});

This isn't quite enough on it's own, because the Modal component has no show property or close() method.

We want to share the show state with the NewPostModal, so we can pass it in as a two-way prop:

<!-- Template for the NewPostModal component -->
<script type="x/template" id="new-post-modal-template">
    <modal :show.sync="show">
        <!-- ... -->
    </modal>
</script>
// The Vue Modal component
Vue.component('Modal', {
  template: '#modal-template',
  props: ['show'],
  ready: function () {
    document.addEventListener("keydown", (e) => {
      if (this.show && e.keyCode == 27) {
        this.close();
      }
    });
  }
});

We can also add a simple close() method that just sets show to false, which conveniently fixes the issue with clicking on the background to close the modal:

// The Vue Modal component
Vue.component('Modal', {
  template: '#modal-template',
  props: ['show'],
  methods: {
    close: function () {
        this.show = false;
    }
  },
  ready: function () {
    document.addEventListener("keydown", (e) => {
      if (this.show && e.keyCode == 27) {
        this.close();
      }
    });
  }
});

There's a subtle problem with this approach though, and that's that our form is no longer being reset when the modal is closed via escape or a background click.

Instead, let's pass in our close behavior as a prop:

<!-- Template for the NewPostModal component -->
<script type="x/template" id="new-post-modal-template">
    <modal :show.sync="show" :on-close="close">
        <!-- ... -->
    </modal>
</script>

Then we can call the function bound to that prop whenever the modal is closed:

// The Vue Modal component
Vue.component('Modal', {
  template: '#modal-template',
  props: ['show', 'onClose'],
  methods: {
    close: function () {
        this.onClose();
    }
  },
  ready: function () {
    document.addEventListener("keydown", (e) => {
      if (this.show && e.keyCode == 27) {
        this.close();
      }
    });
  }
});

Here's where that leaves us:

Finishing off

We've now got a simple, reusable modal component that encapsulates any shared modal behavior.

I won't bother going through every step of building out a second modal (this is quite a long post already), but here's a demo you can play with to see how it would work:

Hopefully this gives you a better idea of how you can extract reusable components and stitch them together with Vue.js.