While working on my course, The Composition API, a very clean abstraction for handling modals using the composition API emerged in the form of a useModal
hook. In this article we will build it from the ground up.
The final API will look something like this:
// Installation
const app = createApp(App)
app.use(ModalPlugin)
app.$mount('#app')
<!-- Usage -->
<template>
<button @click="show">show</button>
<teleport to="#modal-dest" v-if="visible">
<modal-content-here />
</teleport>
</template
<script lang="ts">
import { defineComponent } from 'vue'
import { useModal } from './modal'
export default defineComponent({
setup() {
const modal = useModal()
return {
showModal: modal.show(),
visible: modal.visible
}
}
})
</script>
useModal
hookLet’s start defining the hook. We will do this in a vue
file - Modal.vue
. Create a new component and add the following (there is a lot going on, I explain it directly underneath!)
<template>
</template>
<script lang="ts">
import { defineComponent, ref } from "vue";
const show = ref(false)
export const useModal = () => {
return {
showModal() {
show.value = true
},
hideModal() {
show.value = false
},
visible() {
show.value
}
}
}
</script>
useModal
returns an object with two functions. Two are just a nice public interface to show and hide the modal - and the final just returns whether the modal is current visible or not.
This is far from an ideal implementation - we don’t want visible
to be writable, for example - it would be cleaner to make this a read only property, as opposed to a writable ref
. We will fix this later using Proxy
, the ES6 construct that Vue 3 is built on top of. For now, we will focus on getting everything working then refactor.
Make it work, make it right, make it fast
You can use whatever markup and styling you like. I am using Bulma, a simple CSS only framework. The markup for my modal is like this:
<template>
<div class="modal" :style="style">
<div class="modal-background"></div>
<div class="modal-content">
<div id="modal-dest"></div>
</div>
<button class="modal-close is-large" aria-label="close" @click="hide"></button>
</div>
</template>
We haven’t created the style
binding in the first <div>
or the hide
function in the <button>
. Add those to the <script>
under the useModal
function:
export default defineComponent({
setup() {
const modal = useModal()
const style = computed(() => {
return {
display: show.value ? 'block' : 'none'
}
})
return {
style,
hide: modal.hideModal,
}
}
})
The Bulma modal overlay defaults to display: none
and you make it visible by setting display: block
. We accomplish this by binding to show.value
.
You may have noticed the use of <teleport to="#modal-dest">
right at the start - we need to create that destination now. This should be as high up the DOM tree as possible - we want the modal to overlay above everything else. We could ask the user to insert a <div id="modal">
in their index.html
, but it would be much cleaner if we could do it for them. Let’s do that.
Let’s say index.html
looks like this:
<html>
<!-- ... --->
<body>
<div id="app"></div>
</body>
<!-- ... --->
</html>
We will insert the <div id="modal">
between <body>
and <div id="app">
.
In a new file, modal-plugin.ts
, add the following:
export class ModalPlugin {
static install() {
const el = document.createElement('div')
el.id = 'modal'
document.body.insertAdjacentElement('afterbegin', el)
const app = createApp(ModalApp).mount('#modal')
}
}
We are making a new <div>
for the modal - which is actually another Vue app - to mount on. We use insertAdjacentElement('afterbegin')
to mount the modal app in between <body>
and <div id="app">
. insertAdjacentElement
can insert different ways depending on the first argument, you can read more about it on MDN.
Finally, head to main.ts
(or the root of your app) and use the ModalPlugin
. Mine looks like this:
import { createApp } from 'vue'
import { ModalPlugin } from '../modal-plugin'
import App from './App.vue'
const app = createApp(App)
app.use(ModalPlugin)
app.mount('#app')
And this should be enough to get everything working!
We can use a Proxy
to only expose visible
as a readonly property. This probably isn’t perfect (I am still learning the in and outs of Proxy
as well), but it will still accomplishes what we want. Basically, when using a Proxy
, you define what happens using get
and set
- in this case, we will not implement set
at all, so the user cannot update visible
by visible.value = false
.
We continue exposing hideModal
and showModal
as functions. The implementation is as follows:
interface ModalApi {
hideModal: () => void
showModal: () => void
visible: boolean
}
export const useModal = (): ModalApi => {
return new Proxy<ModalApi>(Object.create(null), {
get(obj, prop) {
if (prop === 'visible') {
return computed(() => show.value)
}
if (prop === 'hideModal') {
return () => show.value = false
}
if (prop === 'showModal') {
return () => show.value = true
}
}
})
}
A better implementation might be with classes
and by using a get
property on visible
- however, I think this is a good opportunity to share a real use case for Proxy
. A good exercise would be to rewrite this using a class
. Something like:
class ModalApi {
showModal() {
// ...
}
hideModal() {
// ...
}
get visible() {
// ...
}
}
At the moment we will show all the modals in the entire application if visible
is true
. To support different modals, an additional flag specifying which modal is required. For example:
modal.showModal({ id: 'signup' })
You could use a unique id, or even pass the actual Component, then render it using a dynamic component. This would work something like this:
modal.showModal({ component: Signup })
<template>
<component :is="modal.component" />
</template>
There are many solutions - pick one that works best for your application.
Another improvement would be to use a constant for the <teleport to"...">
and in the <Modal>
component, since typing strings manually is error prone. For example:
<template>
<teleport :to="modalTarget">
</template>
<script lang="ts">
export default {
setup() {
const modal = useModal()
return {
modalTarget: modal.target // `modal.target could be '#destination' for example.
}
}
</script>
We build a useModal
hook using ref
, computed
and a simple object in combination with <teleport
>. We also explored how we can use Proxy
for more fine-grained control over object access.