2

Background

Typically, when using modals within Vue.js components, it is the norm to create a reusable modal component, and then control the state of this component using events from child components.

For example, consider the following code:

App.vue

<div id="app">

    <!-- Main Application content here... -->

    <!-- Place any modal components here... -->
    <modal ref="ContactForm"></modal>

</div>

ChildComponent.Vue

To open the modal from a child component, we would simply trigger the following event:

bus.$emit('open-modal', 'ContactForm');

Note 1: bus is a separate Vue instance that allows events to be fired between all components regardless of their relation.

Note 2: I have intentionally left out my modal component code as it is not relevant to the question.

The Problem

Whilst the above works absolutely fine, there is one key issue...

In order to add a modal to my application, instead of placing the modal component within the child component that references it, I have to place all modals within App.vue as this ensures they are as high up the DOM tree as possible (to ensure they appear above all content).

As a result, my App.vue has the potential to end up looking like this:

<div id="app">

    <!-- Main Application content here... -->

    <!-- Place any modal components here... -->
    <modal ref="SomeModal1"></modal>
    <modal ref="SomeModal2"></modal>
    <modal ref="SomeModal3"></modal>
    <modal ref="SomeModal4"></modal>
    <modal ref="SomeModal5"></modal>
    <modal ref="SomeModal6"></modal>
    <modal ref="SomeModal7"></modal>

</div>

It would be much cleaner to be able to place the modal component within the DOM of the child component.

However, in order to ensure that the modal appears above all content within the DOM (specifically items with a set z-index), I can't see an alternative to the above...

Can anyone suggest a way in which I can ensure my modals will work correctly, even if they are placed within the child components?

Potential Solution

I did think about the following solution but it seems very dirty...

  1. Fire open-modal event
  2. Move the relevant modal component to the parent App.vue component
  3. Show the modal

Additional Information

In case the above isn't clear, I am trying to avoid defining all modals in my App.vue component, and allow defining of my modals within any child component.

The reason I am not able to do this, at present, is because the HTML for the modals must appear as high in the DOM tree as possible in order to ensure they appear above all content.

Ben Carey
  • 16,540
  • 19
  • 87
  • 169
  • The "norm", as you put it, is to reuse. As in, you only have one modal instance and you inject the contents dynamically when you open it. In fact, the norm today is not to have any modal instance and to create one programmatically when you need it and destroy it after passing the callback response to its triggering component. If you ended up with a bunch of modal instances, you're not reusing. You're hard-coding. – tao Sep 16 '19 at 20:42
  • @AndreiGheorghiu - That was poorly worded by me, the way you have explained it is exactly how my modal component works. As in each `modal` is conditionally rendered, they are not all present in the DOM at once. – Ben Carey Sep 16 '19 at 20:43
  • In that case, all you need is to set `` as the target element of your modal instance, when you append it. That's how you avoid any `z-index` issues. – tao Sep 16 '19 at 20:44
  • @AndreiGheorghiu - hold on, I am a little confused. What do you mean by `target` element? The `modal` is not created by my JS, is it conditionally rendered by Vue.js – Ben Carey Sep 16 '19 at 20:46
  • Do you have a use case where `.modal { z-index: 2000;}` doesn't work? The theoretical max for z-index is something like 2 billion for modern browsers. So I'd stop worrying about where it is in the DOM and add the component where it makes sense. – Bryce Howitson Sep 16 '19 at 20:47
  • @AndreiGheorghiu means using js `body.append( modal HTML ...)` You're placing the modal in the DOM using Vue templates and components vs attaching it dynamically. – Bryce Howitson Sep 16 '19 at 20:48
  • @BryceHowitson - Yes, I absolutely have an example... I won't post my link as the site is not public yet but it's very easy to replicate. Simply create a collection of elements, all with nested `z-index` values, and then place the modal above them in the DOM – Ben Carey Sep 16 '19 at 20:49
  • @BryceHowitson - that's exactly what I assumed he meant. By utilising `body.append()` you are effectively removing all reactivity of the element. The `modal` must be a Vue.js component – Ben Carey Sep 16 '19 at 20:50
  • If you can't get z-index to move an element to the top of the DOM stack you have problems outside of where/how Vue components are being used. However, if you can't post example code it's going to be almost impossible to help you figure out what's going on. – Bryce Howitson Sep 16 '19 at 20:55
  • @BryceHowitson - I just found this github issue that highlights exactly the problem I am facing (ignore the fact that it is bootstrap). It seems there is no solution. In regards to the HTML/CSS, the `z-index` is absolutely fine, as is my code. The issue is simply to do with how I can essentially do a reverse `slot` on a Vue component. I don't believe it is possible as you can see here: https://github.com/bootstrap-vue/bootstrap-vue/issues/1108 – Ben Carey Sep 16 '19 at 20:59

2 Answers2

1

I place my modals in my child components and it works great. My modal implementation is fundamentally similar the modal example from the docs. I also added in basic a11y features including vue-focus-lock, but the idea is the same.

No event bus, shared state, or refs - just v-if the modal into existence when needed.

tony19
  • 125,647
  • 18
  • 229
  • 307
David Weldon
  • 63,632
  • 11
  • 148
  • 146
  • Thank you for your answer, however, it doesn't solve my issue. My `modal` implementation works very similarly to the Vue.js example. I am able to place my modal definitions within my child components and technically speaking, they work. The issue is that by placing them in my child components, Vue renders them in that place within the DOM, and not at the highest point in the DOM tree. This causes problems when the parent element of the modal has a `z-index` value set, and you have siblings, or other child elements with `z-index` set. – Ben Carey Sep 16 '19 at 21:28
  • But that just seems like a css issue. Why not use `position: fixed` with a massive `z-index`? – David Weldon Sep 16 '19 at 21:39
  • Unfortunately it isn't just a CSS issue. Consider a `div` with a `z-index` set to `10`, and then the modal is within this `div`. The maximum `z-index` of the `modal` will then be `10`. You can set the `z-index` to `10000000`, but it will still only be relative to items within `div`. Trust me, I have tested this... :-D – Ben Carey Sep 16 '19 at 21:48
  • Yes, I see your point. This is talked about [here](https://stackoverflow.com/questions/19841997). If you can't remove the stacking context of the parent, then this is tricky... – David Weldon Sep 16 '19 at 22:11
1

Here's what I was talking about:

Create an addProgrammaticComponent function in a helper, along these lines:

import Vue from 'vue';

export function addProgrammaticComponent(parent, component, dataFn, extraProps = {}) {
  const ComponentClass = Vue.extend(component);
  // this can probably be simplified. 
  // It largely depends on how much flexibility you need in building your component
  // gist being: dynamically add props and data at $mount time
  const initData = dataFn ? dataFn() : {};
  const data = {};
  const propsData = {};
  const propKeys = Object.keys(ComponentClass.options.props || {});

  Object.keys(initData).forEach((key) => {
    if (propKeys.includes(key)) {
      propsData[key] = initData[key];
    } else {
      data[key] = initData[key];
    }
  });

  // add store props if you use Vuex

  // extraProps can include dynamic methods or computed, which will be merged
  // onto what has been defined in the .vue file

  const instance = new ComponentClass({
    /* store, */ data, propsData, ...extraProps,
  });

  instance.$mount(document.createElement('div'));

  // generic helper for passing data to/from parent:
  const dataSetter = (data) => {
    Object.keys(data).forEach((key) => {
        instance[key] = data[key];
    });
  };

  // set unwatch on parent as you call it after you destroy the instance
  const unwatch = parent.$watch(dataFn || {}, dataSetter);

  return {
    instance,
    update: () => dataSetter(dataFn ? dataFn() : {}),
    dispose: () => {
        unwatch();
        instance.$destroy();
    },
  };
}

... and now, where you use it:

Modal.vue is a typical modal component, but you can beef it up with close on Esc or Del keyspress, etc...

Where you want to open a modal:

 methods: {
   openFancyModal() {
     const component = addProgrammaticComponent(
       this,
       Modal,
       () => ({
         title: 'Some title',
         message: 'Some message',
         show: true,
         allowDismiss: true,
         /* any other props you want to pass to the programmatic component... */
       }),
     );

     document.body.appendChild(component.instance.$el);

     // here you have full access to both the programmatic component 
     // as well as the parent, so you can add logic

     component.instance.$once('close', component.dispose);

     // if you don't want to destroy the instance, just hide it
     component.instance.$on('cancel', () => {
       component.instance.show = false;
     });

     // define any number of events and listen to them: i.e:
     component.instance.$on('confirm', (args) => {
       component.instance.show = false;
       this.parentMethod(args);
     });
   },
   /* args from programmatic component */
   parentMethod(args) {
     /* you can even pass on the component itself, 
        and .dispose() when you no longer need it */
   }
 }    

That being said, nobody stops you from creating more than one Modal/Dialog/Popup component, either because it might have a different template or because it might have significant additional functionality which would pollute the generic Modal component (i.e: LoginModal.vue, AddReportModal.vue, AddUserModal.vue, AddCommentModal.vue).

Point here being: they are not added to the app (to DOM), until you actually $mount them. You don't place the markup in the parent component. And you can define in the opening fn what props to pass, what to listen to, etc...

Except for the unwatch method, triggered on the parent, all events are bound to the programmaticComponent instance, so there's no garbage.

This is what I was saying when I said no actual hidden modal instance is lurking on DOM until you open it.

Not even saying this approach is necessarily better than others (but it has some advantages). From my POV, it is just inspired by Vue's flexibility and core principles, it is clearly possible, and it allows the flexibility to .$mount and dispose of any component (not only modals) onto/from any component.

It's especially good when you need to open the same component from multiple corners of the same complex app and you're serious about DRY.

See vm.$mount docs.

tony19
  • 125,647
  • 18
  • 229
  • 307
tao
  • 82,996
  • 16
  • 114
  • 150
  • 1
    Thank you, this is a very interesting approach. Essentially you are dynamically creating the component into JS, not the DOM, thus maintaining it's reactivity. Then you append it to the DOM wherever you need to i.e. the `body` element. Based on my research, I think this may be the only way to achieve what is needed. +1 for now but will accept once others have had a chance to chip in :-D – Ben Carey Sep 16 '19 at 21:57
  • @Ben, I reused this method for modals, dialogs, popups and rendering HTML for emails (in emails case I didn't add the programmaticComponent to DOM at all. Just waited for all assets to load, so I could hard-code email image inline style attributes - (thanks Outlook! :) ) and sending. – tao Sep 16 '19 at 22:06