Pull to refresh

All the features of modals for Vue

Level of difficultyEasy
Reading time7 min
Views1.2K

I often see how the topic of modal windows is raised in Discrod, on Reddis, and even on Habr. If you go to the official discord channel Vue and turn on the search for the word modal, you can find that questions are asked every day. Although what can be difficult in modal windows? Create the isOpen variable, add v-if and that's it. This is what I see in 90% of projects. But is this approach so convenient - definitely not.

A couple of years ago, I decided to deal with modals once and for all. This article will explain how developers use modal windows, how elegantly add them to your project. All the described approaches were collected into a single library jenesius-vue-modal and described on GitHub.

I searched the official Discrod channel for messages related to modal and dialog and highlighted 4 main topics that developers raise:

  • how to open a modal window

  • how to open a modal window on top of the previous one

  • how to return a value from a modal window

  • how to attach a modal window to a specific route

Great! We have requirements, now it's time to fulfill them. This article it will be divided into 4 parts, each dedicated to one of the above points. Let's start.

How to open a modal window

It has always been inconvenient for me to insert a modal window component directly into another component. It looks like this:

<!-- widget-user-card.vue -->
<template>
    <div class = "user">
        <!--user-card-->
        <button @click = "openUserModal"></button>
        <!--modal-->
        <modal-user :id = "userId" v-if = "isOpen"/>
    </div>
</template>
<script setup>

const props = defineProps(['userId'])
const isOpen = ref(false);

function openUserModal() {
    isOpen.value = true;
}

</script>

If we have an application with one modal window, then this approach will suit us. But in other situations, this overloads the component, which is why their volume begins to grow.

In the vue of the third version, teleport was added to render components in another part of our application, but this clutters up our file even more. In our project, we added a new abstraction and passed it to a component, which then `teleported" to the place we needed.

Now let's try to make it more elegant and convenient. As can be seen from the requirements, we sometimes need to display multiple windows. Therefore, we will create a dynamic queue in which active modal windows will be stored. We will also describe the openModal function that will be used to open these modal windows:

const modalQueue = reactive([]);

function openModal(component, props) {
    // We need close all opened modals before add new.
    modalQueue.splice(0, modalQueue.length);
    modalQueue.push({component, props})
}

The component to be displayed and the props to be installed in it are passed to the function to open the modal window. Also, do not forget that we need to close all previously opened modal windows.

The functionality is implemented, now we will create a component: a container in which this modalQueue will be displayed:

<!--modal-container.vue-->
<template>
    <component
        v-for = "item in modalQueue"

        :is = "item.component"
        v-bind = "item.props"
    />
</template>

I have removed the description of the CSS classes, the darkening and all other secondary details. Here we see the most important thing:

  • Displaying all components from modalQueue

  • Transfer of all props via 'v-bind`

We also need to add this container to our application. I prefer to add it to the very end of the App.vue components so that modal windows are always on top of other components.

Now let's update our widget-user-card file:

<!-- widget-user-card.vue -->
<template>
    <div class = "user">
        <!--user-card-->
        <button @click = "openUserModal"></button>
    </div>
</template>
<script setup>
const props = defineProps(['userId'])

function openUserModal() {
    openModal(ModalUser, { id: props.userId })
}
</script>

It looks like this:

example open modal
example open modal

We got rid of unnecessary logic in the component, and the code became cleaner. We don't have to keep reactivity when passing props to a function, because the modal window is a new layer of logic. But nothing prevents us from passing the computed variable there.

How to open multiple modal windows

Since we have chosen a reactive array in advance to store modal windows, we simply need to add new data to the end to show a new window. Let's add the pushModal function, which will do the
same as openModal, but without clearing the array:

function pushModal(component, props) {
    modalQueue.push({component, props})
}

It looks like this:

example push modal
example push modal

I can also highlight another approach: only the last modal window is always shown on the page, and the rest are hidden with preservation internal state.

How to return a value from a modal window

This is the most popular question I've come across, because the previous two are intuitive. If we are talking about the return value of modal windows, we must first understand their essence. By default, modal windows are treated as a separate logic layer with its own data model. This approach is convenient and makes the development of a web application with modal windows safe. I think about the same. A modal window is a separate logical layer that accepts input parameters and interacting with them in some way. However, there are cases when the modal window is only part of the process.

The first thing that comes to mind is to pass a callback that will be called by the modal window itself at the end of the process.

openModal(ModalSelectUser, {
    resolve(userId) {
        // Do something
    }
})

Callback-and that's cool, but for me, linear code using Promise is more convenient. For this reason, I implemented the function for returning the value as follows:

function promptModal(component, props) {
    return new Promise(resolve => {
        pushModal(component, {
            ...props,
            resolve
        })
    })
}

As a callback we always pass resolve as props and call it already inside the modal window:

<!--modal-select-user.vue-->
<template>
    <!-- -->
    <button @click = "handleSelect"></button>
</template>
<script setup>
const props = defineProps(['resolve'])

function handleSelect() {
    props.resolve(someData);
}
</script>

It looks like this:

example-prompt-modal
example-prompt-modal

The most simplified example in which the component returns data by sending it to `resolve'. Example of calling this function:

const userId = await promptModal(ModalSelectUser)

For me, this approach looks somehow fresher.

How to attach a modal window to a specific route

And finally integration with 'vue-roter'. The main task: when the user switches to /user/5, display the modal window of the user card.The first thing that comes to mind: in the user-card component at the time of onMount open a modal window, close it at the moment of unMount. This will
work great.

Let's highlight what problems we can expect here and what needs to be taken into account:

  • Updating components in onBeforeRouteUpdate. If we have a transition from user/4 to user/8, onMount will not be called.

  • If the modal window was closed, you need to go back a step in the vue-router. You can return to the previous route by closing the modal directly, or you can use the "back" key on your device. In the second case, it is necessary to control that we do not leave immediately two steps back (clicking on "back", closing modal).

This is not the whole list. You can add window closing handlers to it. For example, if we add hooks to modal windows that will prohibit closing until the user accepts "Consent to data processing", the transition to the desired route should not occur.

We are implementing a basic wrapper function, which we will pass to `Router' when we initialize our application. And gradually we will fill it:

function useModalRouter(component) {
    return {
        setup() {
            //
            return () => null
        }
    }
}

When initializing route, we will wrap modal windows with this function:

const routes = [
    {
        path: "/users",
        component: WidgetUserList,
        children: [
            {
                path: ":user-id",
                component: useModalRouter(ModalUser) // Here
            }
        ]
    }
]

When switching to /users/5, we will not create or install anything. That's why the setup function returns null. Now we need to display a modal window.

function useModalRouter(component) {
    return {
        setup() {
            function init() {
                openModal(component)
            }
            onMounte(init)
            onBeforeRouteUpdate(init)
            onBeforeRouteLeave(popModal);

            return () => null
        }
    }
}

We will also add the popModal method to close the last open modal window:

function popModal() {
    modalQueue.pop();
}

If you try to do through the entire set of hooks onMount, onUnmount, onBeforeRouteUpdate, we will create frankenstein's monster. Also in the example above there is a problem with the transmission of props. We need to solve this somehow. Let's change our approach and review each change router. Yes, this approach may not seem optimal, but we will immediately solve two problems:

  • integration with vue-router

  • closing the modal window when switching to another route.

Eventually we will implement something similar to this:

router.afterEach(async (to) => {
    closeModal(); // [1]
    const modalComponent = findModal(to); // [2]
    if (modal) await modalComponent.initialize(); // [3]
})

Let's take a closer look at what we are doing in this handler:

  • [1] close all modal windows before switching to a new route

  • [2] We are looking for a modal component. To do this, we implemented the findModal function:

function findModal(routerLocation) {
    for(let i = routerLocation.matched.length - 1; i >= 0; i--) {
        const components = routerLocation.matched[i].components;

        const a = Object.values(components).find(route => route._isModal);
        if (a) return a;
    }
    return null;
}

To briefly explain what this function does: it looks for a wrapper that was created using useModalRouter and returns it. If you delve into the topic, then the algorithm is as follows:

  1. For the current route, we get all the matches described for the current route in routes.

  2. We get the component object that were specified for rendering

  3. we are looking for those among them that have the _isModal flag set.

Stop! It is unlikely that Vue has such properties. That's right, we're expanding the useModalRouter method, now it looks like this:

function useModalRouter(component) {
    return {
        setup() { return null },
        _isModal: true
    }
}

We return to the afterEach hook at position [3]. The initialize property is also not in the returned object, so we also add it:

function useModalRouter(component) {
    return {
        initialize() {
            const params = computed(() => router.currentRoute.value.params);
            openModal(component, params);
        }
    }
}

Now, if a user enters the route for which a modal window should be opened, the search and initialization process will take place. Also pay attention to props. Here we pass them as a computed variable. This is not a problem for me, because in a modal container, Vue will independently transform v-bind = "props" to a normal form.

It would not be possible to show how it works in gif. How integration with vue-router works can be viewed on sandbox.

Why do we write our own?

There are several libraries for modal windows to work with, but they do not provide even half of the functionality described above. I just wanted to show that working with modal windows can be pleasant and simple. What I described above is the foundation for this libraries. For a couple of years, I have collected functionality in it that covers all my needs when working with modal windows. Added a large number of tests and described the documentation. Perhaps it will also be useful for someone.

Tags:
Hubs:
Rating0
Comments5

Articles