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](https://webcf.waybackmachine.org/web/20231127091820/https://habrastorage.org/getpro/habr/post_images/20e/34c/1e4/20e34c1e49aeace7683b3238070637cb.gif)
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](https://webcf.waybackmachine.org/web/20231127091820/https://habrastorage.org/getpro/habr/post_images/cc3/80e/426/cc380e426ad17fc75f65e28b137819fa.gif)
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](https://webcf.waybackmachine.org/web/20231127091820/https://habrastorage.org/getpro/habr/post_images/9c9/ee9/054/9c9ee9054a182fa795c76c746ec74957.gif)
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 fromuser/4
touser/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:
For the current route, we get all the matches described for the current route in routes.
We get the component object that were specified for rendering
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.