The modal box is a common component used when less but more important content needs to be displayed. It needs to capture the user’s focus immediately, so it is often designed with a centered dialog box and a black translucent mask behind it. The overall style doesn’t change much, but the content does.

The contents of a modal box may not change on a single page, so you can write a static component to put on the page and control its display and hide as needed. But for multiple pages with different content, putting a modal box component on each page is a bit wasteful and inflexible. Another scenario, where the content is indeterminate, requires a modal box to dynamically create elements and display them. We also want to get feedback from the user when the modal box is closed through a function call.

For example, the home page is accessible, but some pages require login. The login mode box will pop up after clicking on a page requiring login. After the user enters the login information, the code will continue at the click point and enter the corresponding page after invoking the interface to log in. This operation link cannot be broken.

To sum up, the core requirements are as follows:

  • A single global component
  • Dynamically creating content
  • Global functions control explicit and implicit
  • Returns data when closed

In addition, it is better to:

  • It can be used as a global component or as a normal component
  • Content and masks can have different display hide animations

Okay, next implement such a component with Vue3.

The basic components

Define modal components.

template

The base component is just a normal component with the following template:

<div v-show="exist">
    <transition :name="maskTransition" @after-leave="handle_afterLeave">
        <div v-show="visible" class="mask" @click="handle_mask_click"></div>
    </transition>
    <transition :name="contentTransition">
        <div v-show="visible" class="content">
            <slot :visible="visible"></slot>
        </div>
    </transition>
</div>
Copy the code

The. Mask element is a mask element, and the. Content element is a content element. The specific content is accessed through the slot, and the visible variable is passed to the slot to inform the explicit and implicit changes of the modal box. There are two transition elements for the mask and the content, so you can set different animations.

Show and hide animations

Here two variables exist and visible are used to control the explicit and implicit of elements, bound to the element’s V-show. One controls the whole and the other controls the mask and content:

data() {
    return {
        exist: false.// Control the display
        visible: false // Control animation}}Copy the code

Exist is bound to modelValue so that v-MDOEL can be used outside the component to control explicit and implicit:

props: {
    modelValue: {
        type: Boolean.default: false}}watch: {
    modelValue: {
        handler() {
            if (this.modelValue) {
                this.show()
            } else {
                this.hide()
            }
        },
        immediate: true}}Copy the code

Two variables are needed because one variable (only exist) causes a mutation in the hidden animation:

As can be seen from the figure above, the modal box appears with fade animation, but disappears without animation. This is because when exist becomes false, the element’s display changes to None, and the animation of its internal element will not play.

To solve this problem, two variables that control explicit and implicit are set. When the mask layer is clicked, set Visible to False, the mask element and content element play animations, and the Update :modelValue event is fired to tell the component that the external element is hidden. So show and hide functions need to be written like this:

show() {
    this.exist = true
    this.visible = true
}
hide() {
    this.visible = false
    this.$emit('update:modelValue'.false)}Copy the code

Notice that exist is not set to false in the hide function. Instead, the transition element’s after-leave event is used to inform the component that the animation has ended, so you can set exist to false to actually hide the element.

handle_afterLeave() {
    if (!this.visible) {
        this.exist = this.modelValue
        this.$emit('close')}}Copy the code

This shows and hides with gradient animation.

Global components

Once you have a base component, you need a global component to wrap the base component, which controls the global explicit and implicit content of the component. Name it global-Modal.

template

<modal
          v-model="visible"
          @close="handle_close"
          >
    <div ref="container"></div>
</modal>
Copy the code

So this modal is the basic component that we just did, and it controls explicit and implicit with a visible member variable. Pass an element into the slot as a container for your content, and set ref=”container” because you will add child elements dynamically later.

Broadcast subscription model

The first step is to be able to control this property globally, and the broadcast subscription model is a good choice. Add the always-present global-Modal component under the root component to listen for Modal broadcasts and dynamically display them based on the content component in the broadcast data.

There are several ways to implement broadcast, but let’s say it looks like this:

Broadcast.on('modal'.this.handle_modal)
Copy the code

This function is called in the Global-Modal created lifecycle hook, and handle_Modal is used to handle operations after receiving modal messages:

handle_modal({component, props = {}, callback = () => {}}) {
    this.callback = callback

    this.render(component, props)
}
Copy the code

Where Component is the content component (Vue component, that is, the component that needs to be displayed in the modal box), props is the property of the content element passed in, and callback is the callback when the modal box is closed. After the callback is saved, the render function is used to create a content element that is then inserted into the previously mentioned.container element using ref. The rendering function looks like this:

render(component: DefineComponent, props: Props) {
    let app = createApp(component, props)
    app.use(this.$store).use(this.$router)
    this.app = app

    if (this.$refs.container) {
        let instacne = app.mount(this.$refs.container)
        instacne.Modal = {
            resolve: this.resolve
        } // Inject utility functions with delay in taking effect
        this.instance = instacne

        this.visible = true}},Copy the code

Use createApp directly to create a Vue instance. Since it is a standalone instance and not in the project’s Vue instance, it also needs to set its current router and store. We then mount the newly created vue instance into the container element and get the root component instance, the content component instance, from the return value of the app.mount function.

In order to control the explicit and implicit of the Modal box flexibly in the content component, a Modal object is injected into the content component, and its resolve function is used to close the Modal box and to pass data to it.

The resolve function looks like this:

resolve(res) {
    if (this.callback) {
        this.callback(res)
    }

    if (this.instance) {
        this.instance.$emit('Modal-Resolve')}this.visible = false
}
Copy the code

If a callback function is passed in when the global modal box is called, it will be called when the modal box is closed and passed the data passed by the content component. The entire data link: display modal box –> content component interaction —-> Pass data, and it will pass.

The resolve function can be called not only by the content component, but also by the modal box itself, such as when a mask is clicked. The resolve function fires the modal-Reoslve event of the content component, allowing it to do some finishing work.

After closing the modal box, there is some cleaning up to do, such as destroying content components and resetting Settings, to facilitate future calls.

conclusion

To implement a global replaceable content modal box, you can first create a normal modal box component, add it to the root component, listen for the global broadcast, then dynamically display the broadcast passed content component, and use the callback function to send back and forth the data processed by the content component.

Of course, in addition to this, a modal box needs to consider the following questions:

  • Style configurable
  • Prevents the callback function from being executed more than once
  • Handling route Changes

And so on, see the details of the source code.

The source code

modal

index.vue

<template>
  <div v-show="exist" class="w-modal" :class="[`horizontal-${horizontal}`, `vertical-${vertical}`]" :style="{ 'z-index': z }">
    <transition :name="maskTransition" @after-leave="handle_afterLeave">
      <div v-show="visible" class="mask" @click="handle_mask_click"></div>
    </transition>
    <transition :name="contentTransition">
      <div v-show="visible" class="content">
        <slot :visible="visible"></slot>
      </div>
    </transition>
  </div>
</template>

<script src="./component.js"></script>
<style src="./style.scss" lang="scss" scoped></style>
Copy the code

component.ts

import { defineComponent } from 'vue'

export default defineComponent({
  name: 'modal'.data() {
    return {
      exist: false.// Control the display
      visible: false // Control animation}},props: {
    modelValue: {
      type: Boolean.default: false
    },
    z: {
      type: Number.default: 1
    },
    horizontal: {
      type: String.default: 'center'.validator: (v: string) = > ['left'.'right'.'center'].includes(v)
    },
    vertical: {
      type: String.default: 'center'.validator: (v: string) = > ['top'.'bottom'.'center'].includes(v)
    },
    maskTransition: {
      type: String.default: 'fade'.validator: (v: string) = > ['fade'.'slide-left'.'slide-right'.'slide-up'.'slide-down'].includes(v)
    },
    contentTransition: {
      type: String.default: 'fade'.validator: (v: string) = > ['fade'.'slide-left'.'slide-right'.'slide-up'.'slide-down'].includes(v)
    }
  },
  watch: {
    modelValue: {
      handler() {
        if (this.modelValue) {
          this.show()
        } else {
          this.hide()
        }
      },
      immediate: true}},methods: {
    / * * *@name End of processing animation */
    handle_afterLeave() {
      if (!this.visible) {
        this.exist = this.modelValue
        this.$emit('close')}},/ * * *@name Handle mask click */
    handle_mask_click() {
      this.hide()
    },

    / * * *@name According to * /
    show() {
      this.exist = true
      this.visible = true
    },
    / * * *@name Hidden *@description Completely hide */ in handle_afterLeave after the animation ends
    hide() {
      this.visible = false
      this.$emit('update:modelValue'.false)
      this.$emit('hiding')}}})Copy the code

style.scss

.modal {
  display: flex;
  position: fixed;
  left: 0;
  top: 0;
  width: 100vw;
  height: 100vh;

  &.horizontal-left {
    justify-content: flex-start;
  }
  &.horizontal-center {
    justify-content: center;
  }
  &.horizontal-right {
    justify-content: flex-end;
  }
  &.vertical-top {
    align-items: flex-start;
  }
  &.vertical-center {
    align-items: center;
  }
  &.vertical-bottom {
    align-items: flex-end;
  }

  .mask {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    background-color: hsla(0.0.0.0.7);
  }

  .content {
    position: relative; }}@keyframes fade {
  from {
    opacity: 0;
  }
  to {
    opacity: 1; }}.fade-enter-active {
  animation: fade 0.3 s ease;
}
.fade-leave-active {
  animation: fade 0.3 s ease reverse;
}
@keyframes slide-left {
  from {
    transform: translate(100%.0);
  }
  to {
    transform: translate(0.0); }}.slide-left-enter-active {
  animation: slide-left 0.3 s ease;
}
.slide-left-leave-active {
  animation: slide-left 0.3 s ease reverse;
}
@keyframes slide-right {
  from {
    transform: translate(-100%.0);
  }
  to {
    transform: translate(0.0); }}.slide-right-enter-active {
  animation: slide-right 0.3 s ease;
}
.slide-right-leave-active {
  animation: slide-right 0.3 s ease reverse;
}
@keyframes slide-up {
  from {
    transform: translate(0.100%);
  }
  to {
    transform: translate(0.0); }}.slide-up-enter-active {
  animation: slide-up 0.3 s ease;
}
.slide-up-leave-active {
  animation: slide-up 0.3 s ease reverse;
}
@keyframes slide-down {
  from {
    transform: translate(0, -100%);
  }
  to {
    transform: translate(0.0); }}.slide-down-enter-active {
  animation: slide-down 0.3 s ease;
}
.slide-down-leave-active {
  animation: slide-down 0.3 s ease reverse;
}
Copy the code

global-modal

index.vue

<template>
  <ui-modal
    class="modal z-modal"
    v-model="visible"
    :position="options.position"
    :maskTransition="options.maskTransition"
    :wrapTransition="options.wrapTransition"
    @close="handle_close"
  >
    <div ref="container"></div>
  </ui-modal>
</template>

<script src="./component.js"></script>
Copy the code

component.ts

import { defineComponent, DefineComponent, createApp, App } from 'vue'
import Broadcast from 'broadcast'
import Modal from 'modal'

export default defineComponent({
  name: 'global-modal'.components: {
    [Modal.name]: Modal
  },
  data() {
    return {
      visible: false.container: null.app: null.instance: null.resolved: false.options: {
        position: [].maskTransition: ' '.wrapTransition: ' '.single: false.routeBackClose: false},callback: () = >{}}},watch: {
    visible() {
      if (!this.visible) {
        this.resolve()
      }
    }
  },
  created() {
    Broadcast.on('modal'.this.handle_modal)
    window.addEventListener('popstate'.this.handle_popstate)
  },
  mounted() {
    this.container = this.$refs.container
  },
  beforeUnmount() {
    window.removeEventListener('popstate'.this.handle_popstate)
  },
  methods: {
    / * * *@name Handle modal events *@param Component Content component *@param Props attribute *@param Options * position: Array. The default center. Top,bottom,left,right * maskTransition: String. Container gradient animation * wrapTransition: String. Content gradient animation * single: Boolean. True by default. Whether singleton * routeBackClose: Boolean. True by default. Disable * when the route is backed up@param Callback function */
    handle_modal({ component, props = {}, options = {}, callback = () => {} }) {
      if (options.single && this.instance) {
        return
      }

      this.options = options
      this.callback = callback

      this.render(component, props)
    },
    / * * *@name Handle _ route jump */
    handle_popstate() {
      if (this.visible && this.options.routeBackClose) {
        this.visible = false}},/ * * *@name Handle close */
    handle_close() {
      this.clear()
    },

    / * * *@name Render *@param Component Content component *@param * / props attribute
    render(component, props) {
      let app = createApp(component, props)
      app.use(this.$store).use(this.$router)
      this.app = app

      if (this.container) {
        let instacne = app.mount(this.container)
        instacne.Modal = {
          resolve: this.resolve
        } // Inject utility functions with delay in taking effect
        this.instance = instacne

        this.visible = true}},/ * * *@name Closed *@param Res returns data */
    resolve(res) {
      if (!this.resolved && this.callback) {
        this.callback(res)
      }

      if (this.instance) {
        this.instance.$emit('Modal-Resolve')}this.visible = false
      this.resolved = true
    },
    / * * *@name Aftermath * /
    clear() {
      if (this.app) {
        this.app.unmount()
        this.app = null
        this.instance = null
      }

      this.resolved = false
      this.options = {
        position: [].maskTransition: ' '.wrapTransition: ' '.single: false.routeBackClose: false
      }
      this.callback = () = >{}}}})Copy the code