Split modal and its contents - slots?

Currently I have a modal data input component, like this:

<template>
  <div class="modal">
    <div class="modal-body">
      <div>Enter data: <input></div>
    </div>
    <div class="modal-footer">
      <button v-if="!hasValidationErrors">Save</button>
      <button>Cancel</button>
    </div>
  </div>
</template>

which then is used in some page as:

<data-input-modal></data-input-modal>

I would like to split up the modal functionality and the data input functionality into reusable components. So my initial idea was to have the modal component like this:

<template>
  <div class="modal">
    <div class="modal-body">
      <slot></slot>
    </div>
    <div class="modal-footer">
      <slot name="footer"></slot>
    </div>
  </div>
</template>

which then would be used like this:

<modal>
  <data-input></data-input>
</modal>

The problem is: How can the data-input component supply the contents for the footer slot of the modal component?

The slot content is rendered in the parent scope. For example:

<modal>
  <data-input v-model="text"></data-input>
  <div slot="footer">
    {{ text }}
  </div>
</modal>

Yes, I know, that’s the problem. :slight_smile:

I’m looking for a good way how the data-input can provide the contents for the other slot. Ideally the parent component shouldn’t need to know about hasValidationErrors.

In my opinion this is the “good way”. A parent component stores state, and uses this state to instruct child components on what should be rendered.

<template>
  <modal>
    <div slot="body">
      <input type="text" v-model="text">
    </div>
    <div slot="content">
      <button>Cancel</button>
      <button :disabled="!valid">Save</button>
    </div>
  </modal>
</template>

<script>
import Modal from '@/components/Modal'

export default {
  name: 'input-modal',
  components: {
    Modal,
  },
  data () {
    return {
      text: ''
    }
  },
  computed: {
    valid () {
      return this.text.length > 5
    }
  }
}
</script>

I had something like in your example, but then I realized that it doesn’t allow me to use the data-input component outside of a modal.

Update: This doesn’t actually work, associated-data-input is usually not updated when data-input is mounted. :frowning: Only when the data-input component has an array in its data and this array is pushed to, then associated-data-input is updated to the ref.


I found another pretty nice way. I made a separate buttons component and I’m passing it a reference to the data input component:

<modal>
  <data-input ref="di"></data-input>
  <template slot="footer">
    <buttons :associated-data-input="$refs.di"></buttons>
  </template>
</modal>

And then in the buttons component:

<template>
  <button v-if="ready && !associatedDataInput.hasValidationErrors">Save</button>
  <button>Cancel</button>
</template>

<script>
  export default {
    props: ["associatedDataInput"],
    computed: {
      ready() {
        return this.associatedDataInput!= null
      }
    }
  }
</script>

This way the two components are tightly coupled (which is a good thing in this case because they are essentially a single component) and the parent component does not need to know anything about the internal state of the two components.

Theoretically one could still test them separately by mocking the data input component, but since they belong together they should probably be tested together anyway.

The ready is necessary to wait for the data input component to be mounted, otherwise the reference would be undefined.

Ok, now here is a version that works:

<modal>
  <data-input ref="di"></data-input>
  <template slot="footer">
    <buttons :refs="$refs" associated-data-input="di"></buttons>
  </template>
</modal>

And then in the buttons component:

<template>
  <button v-if="ready && !dataInput.hasValidationErrors">Save</button>
  <button>Cancel</button>
</template>

<script>
  export default {
    props: ["refs", "associatedDataInput"],
    data() {
      return { dataInput: "" };
    }
    mounted() {
      this.dataInput = this.refs[this.associatedDataInput]
    },
    computed: {
      ready() {
        return this.dataInput != null
      }
    }
  }
</script>

Here is a solution with provide and inject, without using $refs, only with reactive data: https://jsfiddle.net/Akryum/ekf2oyef/
Documentation of provide/inject