As a both Vue.js and React developer, I’ve been assigned to/leading various projects which were mostly about building UIs. One thing I was always battling was popup Dialogs — not in the sense that they didn’t work, but I was always left wondering whether there’s a better way to code them.
I’ve also seen other approaches, mostly storing modal window states in global app store, which of course required developers to store more and more data as the app grew — and so did the number of used modal windows. The store quickly became bloated with data that was relevant only for a short amount of time for a small part of the UI.
For those interested only in the final open source code, it’s at the end of the article as usual!
While building SPAs with Vue.js, my approach was mostly to create a <Modal />
wrapper component (with a slot
for the content) which held an internal boolean open state and had methods such as open()
and close()
, which toggled its state. I accessed those methods through a $ref
in the Component that displayed the modal. An example could look similar to this:
methods: {
openModal () {
this.$refs.modal.show()
}
}
I liked this approach in a sense that each Component had its own modal and was responsible for it. Thanks to this the application wasn’t prone to monolithic structure. I don’t much like keeping the modal state inside the Component that’s displaying the modal (and passing it as a prop) because the code often becomes way too repetitive and robust if you have to change the modal state in the displaying components. That’s why I chose the approach where the modal took care of its own open state and exposed the toggle methods through $ref.
The downside to this was that there were simply too many <Modal />
components as my app grew. They were also inside the Component that was displaying them HTML wise, so there were sometimes overlay issues which z-index simply couldn’t solve. I was wondering whether there’s a better way.
I already mentioned the z-index issue. When you need to display something on the top of everything else HTML wise, it only makes sense that your tree should be structured accordingly, instead of using hacks to force your elements to appear on the top of your site when actually… they’re not.
So we’re aiming for something like this
<!doctype html>
<html>
<body>
<div id="app">
<div class="content">Lorem Ipsum..</div>
<div id="modal-root"></div>
</div>
</body>
</html>
Where #modal-root
will always be on the bottom (but top, visually 😅) of your structure and it will always display your current selected element.
That means if you’re aiming to have multiple modals in your app, this is not an ideal approach for you. I will go with cases where I need only one dialog open at a time.
We will accomplish this reactivity of the root element by using EventBus, so any child can trigger opening the dialog displaying any component from anywhere.
import Vue from 'vue'
export const ModalBus = new Vue()
VueModalBus - eventBus.js
Let’s start by creating a simple ModalBus
inside our eventBus.js file. I chose a named export because I aim to work with more event buses, but that’s really a personal preference. If you feel comfortable using default exports, feel free to do so.
<template>
<div class="modal-backdrop"
v-show="isOpen"
:class="{open: isOpen}"
@click="$emit('onClose')">
<div class="modal-dialog" :class="{open: isOpen}" @click.stop>
<div class="modal-title" v-if="title">{{ title }}</div>
<div class="modal-body">
<slot />
</div>
</div>
</div>
</template>
<script>
export default {
props: {
isOpen: Boolean,
title: String
}
}
</script>
<style scoped>
.modal-backdrop {
background: rgba(250, 250, 250, 0.8);
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center
}
.modal-dialog {
width: 30rem;
background: rgb(255, 255, 255);
padding: 1.5rem 2rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
border-radius: 0.3rem;
}
.modal-title {
font-weight: bold;
font-size: 1.3rem;
margin-bottom: 1rem;
color: rgb(100, 100, 100);
}
.modal-body {
color: rgb(180, 180, 180);
}
</style>
VueModalBus - Modal.vue
As you can see, this is merely a presentational component. It gets the isOpen
and title
properties from a parent we don’t have yet.
We handle several things in here:
onClose
call when we click the backdrop outside the dialog<slot />
to display children inside the Modal<template>
<Modal :isOpen="!!component" :title="title" @onClose="handleClose">
<component :is="component" @onClose="handleClose" v-bind="props" />
</Modal>
</template>
<script>
import { ModalBus } from '../eventBus'
import Modal from './common/Modal'
export default {
data () {
return {
component: null,
title: '',
props: null
}
},
created () {
ModalBus.$on('open', ({ component, title = '', props = null }) => {
this.component = component
this.title = title
this.props = props
})
document.addEventListener('keyup', this.handleKeyup)
},
beforeDestroy () {
document.removeEventListener('keyup', this.handleKeyup)
},
methods: {
handleClose () {
this.component = null
},
handleKeyup (e) {
if (e.keyCode === 27) this.handleClose()
}
},
components: { Modal },
}
</script>
VueModalBus - ModalRoot.vue
The ModalRoot
component is the one that’s listening to the ModalBus
events and handling all the logic — and the one that’s displaying the <Modal />
. It’s also the one we want to place inside our <App />
on the bottom of our HTML structure.
Let’s debunk what’s happening under the hood:
We’re storing 3 things in our state (data):
data () {
return {
component: null,
title: '',
props: null
}
},
Those properties will be received via the ModalBus
and they’re carrying the information about the component we’re going to display, the dialog’s title and any needed props for the child component inside the <Modal />
.
created () {
ModalBus.$on('open', ({ component, title = '', props = null }) => {
this.component = component
this.title = title
this.props = props
})
document.addEventListener('keyup', this.handleKeyup)
},
Right upon the <ModalRoot />
's creation, we’re connecting to the ModalBus
and listening to the open
event, which is what we’ll call when we want to open the Modal from any component, passing our values in. You can see we’re accepting and setting those parameters that I mentioned above — component, title and props. We’re also adding a listener for the (Escape) key.
beforeDestroy () {
document.removeEventListener('keyup', this.handleKeyup)
},
We have to be careful and destroy the listener before the <ModalRoot />
itself is destroyed.
methods: {
handleClose () {
this.component = null
},
handleKeyup (e) {
if (e.keyCode === 27) this.handleClose()
}
},
Then there’s our simple handleClose()
method which is only setting component to null (which changes our isOpen
prop for the <Modal />
component, more in the template section) and handleKeyup()
method, which is checking whether we’re pressing the Escape key and then calling handleClose()
, should the condition be met.
(Note that you can also listen to the
_close_
event the same way you listen to_open_
and call handleClose() inside, so the modal can also be closed from anywhere)
<Modal />
presentational component to wrap our dynamic <component />
isOpen
and title
props to <Modal />
based on our incoming data (isOpen is true whenever component’s not empty)@onClose
listener to react to <Modal />
's backdrop click, which is when an $emit('onClose')
happens in the <Modal />
<component :is="component" @onClose="handleClose" v-bind="props" />
<component />
inside the <Modal />
, therefore making use of the <slot />
functionality inside it@onClose
emit (the same way like in <Modal />
)props
we set in the open()
ModalBus call to the <component />
, so they find their way to the destined component.Everything should be connected now — we just need to make use of it! I’ve created a few examples which are using Tailwind to show how the <ModalRoot />
can actually be used.
<template>
<div id="app">
<div class="content">
<Button @click="openSuccessAlert" class="mr-2">
Success alert
</Button>
<Button @click="openDangerAlert" class="mr-2">
Danger alert
</Button>
<Button @click="openClosableInside" class="mr-2">
Close from inside
</Button>
<Button @click="openSignIn">
Sign in form
</Button>
</div>
<ModalRoot />
</div>
</template>
<script>
import { ModalBus } from './eventBus';
import ModalRoot from './components/ModalRoot'
import Button from './components/common/Button'
import SignInForm from './components/examples/SignInForm'
import Alert from './components/examples/Alert';
import ClosableInside from './components/examples/ClosableInside';
export default {
name: 'app',
components: {
Button,
ModalRoot
},
methods: {
openSuccessAlert () {
ModalBus.$emit('open', {
component: Alert,
props: { text: 'Everything is working great!', type: 'success' }
})
},
openDangerAlert () {
const props = {
type: 'error',
text: 'The server returned 500 again! omg!'
}
ModalBus.$emit('open', { component: Alert, title: 'An error has occured', props })
},
openClosableInside () {
ModalBus.$emit('open', { component: ClosableInside, title: 'Close dialog from component' })
},
openSignIn () {
ModalBus.$emit('open', { component: SignInForm, title: 'New group' })
}
}
}
</script>
<style scoped>
.content {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center
}
</style>
VueModalBus - App.vue
1. Success alert
The first case is very simple. We’re calling a generic <Alert />
component and passing some basic config in:
openSuccessAlert () {
ModalBus.$emit('open', {
component: Alert,
props: { text: 'Everything is working great!', type: 'success' }
})
},
The <Alert />
component is expecting a text
and a type
. We’re not passing any title for our dialog, so it won’t be displayed.
<template>
<div :class="classes">
<span class="block sm:inline">{{ text }}</span>
</div>
</template>
<script>
export default {
props: {
type: {
type: String,
value: 'error' | 'info' | 'success' | 'warning'
},
text: String
},
computed: {
color () {
switch (this.type) {
case 'error':
return 'red'
case 'success':
return 'teal'
case 'warning':
return 'orange'
case 'info':
default:
return 'gray'
}
},
classes () {
const { color } = this
return [`bg-${color}-400 border border-${color}-200 text-white px-4 py-3 rounded-lg relative`]
}
},
}
</script>
VueModalBus - Alert.vue
2. Danger alert
openDangerAlert () {
const props = {
type: 'error',
text: 'The server returned 500 again! omg!'
}
ModalBus.$emit('open', { component: Alert, title: 'An error has occured', props: props })
},
We’re using the <Alert />
component again, this time passing the "error"
type and also providing a title
for the dialog.
3. A Component that’s closable from inside
openClosableInside () {
ModalBus.$emit('open', { component: ClosableInside, title: 'Close dialog from component' })
},
We’re not passing anything special to the open()
call, instead the magic happens inside the <ClosableInside />
example component:
<template>
<div>
<p class="mb-4">
This dialog is closable from the inside of the component!
</p>
<div class="text-right">
<Button @click="$emit('onClose')" color="gray">Close</Button>
</div>
</div>
</template>
<script>
import Button from '../common/Button';
export default {
components: { Button }
}
</script>
VueModalBus - ClosableInside.vue
<Button @click="$emit('onClose')" color="gray">Close</Button>
Because the <ModalRoot />
is displaying the component
, in our case the <ClosableInside />
component, and it’s listening to the @onClose
event, we can $emit it inside the component and the modal window will close. The “React way” would be passing this close handler via props, which is also possible, of course.
4. A Sign In form
This form is almost completely copied from the Tailwind documentation and it’s included for presentational purposes. It doesn’t do much, but you can pass in whatever props you like and work with them.
openSignIn () {
ModalBus.$emit('open', { component: SignInForm, title: 'New user' })
}
<template>
<form class="mb-4">
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2">
Username
</label>
<input
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
type="text" placeholder="Username">
</div>
<div class="mb-6">
<label class="block text-gray-700 text-sm font-bold mb-2">
Password
</label>
<input
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
type="password" placeholder="******************">
</div>
<div class="flex items-center justify-between">
<button
class="bg-teal-500 hover:bg-teal-600 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
type="button">
Sign In
</button>
<a class="inline-block align-baseline font-bold text-sm text-teal-500 hover:text-teal-800" href="#">
Forgot Password?
</a>
</div>
</form>
</template>
VueModalBus - SignInForm.vue
The beautiful thing about this is you can extend it whatever way you want. Let’s say we want to add a modal animation!
<template>
<transition name="fade">
<div class="modal-backdrop"
v-show="isOpen"
:class="{open: isOpen}"
@click="$emit('onClose')">
<div class="modal-dialog" :class="{open: isOpen}" @click.stop>
<div class="modal-title" v-if="title">{{ title }}</div>
<div class="modal-body">
<slot />
</div>
</div>
</div>
</transition>
</template>
<script>
export default {
props: {
isOpen: Boolean,
title: String
}
}
</script>
<style scoped>
.fade-enter-active, .fade-leave-active {
transition: 0.5s;
}
.fade-enter, .fade-leave-to {
opacity: 0;
}
.fade-enter .modal-dialog, .fade-leave-to .modal-dialog {
transform: translateY(-20%);
}
.modal-backdrop {
background: rgba(250, 250, 250, 0.8);
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center
}
.modal-dialog {
width: 30rem;
background: rgb(255, 255, 255);
padding: 1.5rem 2rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
border-radius: 0.3rem;
transition: 0.5s;
}
.modal-title {
font-weight: bold;
font-size: 1.3rem;
margin-bottom: 1rem;
color: rgb(100, 100, 100);
}
.modal-body {
color: rgb(180, 180, 180);
}
</style>
VueModalBus - ModalAnimated.vue
We pretty much just added a <**transition name=”fade”**>
and some CSS rules in the <style />
section:
.fade-enter-active, .fade-leave-active {
transition: 0.5s;
}
.fade-enter, .fade-leave-to {
opacity: 0;
}
.fade-enter .modal-dialog, .fade-leave-to .modal-dialog {
transform: translateY(-20%);
}
A pretty common case is where you want to persist the modal’s (open) state on backdrop click, for example when you’re filling a form and you want to protect the user from accidentally closing the window.
<template>
<Modal :isOpen="!!component" :title="title" @onClose="handleOutsideClick">
<component :is="component" @onClose="handleClose" v-bind="props" />
</Modal>
</template>
<script>
import { ModalBus } from '../eventBus'
import Modal from './common/Modal'
export default {
data () {
return {
component: null,
title: '',
props: null,
closeOnClick: true
}
},
created () {
ModalBus.$on('open', ({ component, title = '', props = null, closeOnClick = true }) => {
this.component = component
this.title = title
this.props = props
this.closeOnClick = closeOnClick
})
document.addEventListener('keyup', this.handleKeyup)
},
beforeDestroy () {
document.removeEventListener('keyup', this.handleKeyup)
},
methods: {
handleOutsideClick () {
if (!this.closeOnClick) return
this.handleClose()
},
handleClose () {
this.component = null
},
handleKeyup (e) {
if (e.keyCode === 27) this.handleClose()
}
},
components: { Modal }
}
</script>
VueModalBus - ModalRootConfirmClose.vue
<ModalRoot />
is storing another property in the data()
now — **closeOnClick**: **true**
. We altered the ModalBus.$on(**‘open’, ...**)
function to accept the new param.ModalBus.$on('open', ({ component, title = '', props = null, closeOnClick = true }) => {
this.component = component
this.title = title
this.props = props
this.closeOnClick = closeOnClick
})
Let’s create a different handler for the <Modal />
's @onClose
event, which gets emitted when a user clicks the backdrop of the <Modal />
.
@onClose="handleOutsideClick"
The body of the function now determines whether to call handleClose()
or not based on the **closeOnClick**
property
handleOutsideClick () {
if (!this.closeOnClick) return
this.handleClose()
},
And that’s it! Let’s try it with the <SignInForm />
component to see if it works by passing the closeOnClick
parameter (now you can close it using only the Escape key):
openSignIn () {
ModalBus.$emit('open', { component: SignInForm, title: 'New user', closeOnClick: false })
}
#vuejs #javascript #programming