Search code examples
vue.jsvuejs2bootstrap-vuev-modelvue-slot

How can I use v-bind and v-on to bind on custom named model in Vue?


I'm making an InputWrapper component used for decorating some BootstrapVue input components. The point is to automatically handle validation states, messages, styling, etc. (not shown in the example bellow) around a given input.

I would like to dynamically "forward" v-model. The problem comes when the wrapped component uses custom model attribute and update event for two way binding.

The main idea goes as follow.

InputWrapper.Vue

    <template>
      <div>
        <slot v-bind="wrappedBindings"></slot>
      </div>
    </template>
    
    <script>
    export default {
      props: {
        value: {required: true}
      },

      methods: {
        onInput($event){
          this.$emit('input', $event);
        }
      },
      
      computed: {
        wrappedBindings(){
          return {
            attrs: {
              value: this.value
            },
            on: {
              input: $event => this.onInput($event),
              'update:value': $event => this.onInput($event)
            }
          }
        }
      }
    }
    </script>

Usage

    <div>
      <input-wrapper v-model="selectModel" v-slot="{attrs, on}">
        <!-- v-model of b-form-select is :value and @input so this works -->
        <b-form-select v-bind="attrs" v-on="on" ...other stuff...></b-form-select>
      </input-wrapper>

      <input-wrapper v-model="inputModel" v-slot="{attrs, on}">
        <!-- v-model of b-form-input is :value and @update (not @update:value or @input) so this does not work -->
        <b-form-input v-bind="attrs" v-on="on" ...other stuff...></b-form-input>
      </input-wrapper>

      <input-wrapper v-model="checkModel" v-slot="{attrs, on}">
        <!-- v-model of b-form-checkbox is :checked (not :value) and @input so this does not work -->
        <b-form-checkbox v-bind="attrs" v-on="on" ...other stuff...></b-form-checkbox>
      </input-wrapper>
    </div>

My current and unsatisfactory solution

    <div>
      <input-wrapper v-model="inputModel" v-slot="{attrs, on}">
        <b-form-input v-bind="attrs" v-on="on" @update="on.input" 
          ...other stuff...></b-form-input>
      </input-wrapper>

      <input-wrapper v-model="checkModel" v-slot="{attrs, on}">
        <b-form-checkbox v-bind="attrs" v-on="on" :checked="attrs.value"
         ...other stuff...></b-form-checkbox>
      </input-wrapper>
    </div>

This solution allows me to do what I want but it's longer to implement and you always need the BootstrapVue documentation close by.

Another solution would be to make a custom component for every BsVue input, but I would also need to forward every attribute and event to the custom component. There are many reasons for not doing this but mainly it would be harder to maintain.

My question is the following: How can I use v-bind="attrs" and v-on="on" to dynamically bind any custom v-model attribute and event without knowing them beforehand?


Solution

  • This one is not easy...

    How can I use v-bind="attrs" and v-on="on" to dynamically bind any custom v-model attribute and event without knowing them beforehand?

    Well you can't.

    My first idea was to somehow reach to a model option used in Vue 2 to customize the prop name and event name of the v-model on this component. But unfortunately this is not accessible on $scopedSlots.default() (and using it in that way would be very ineffective anyway)

    Imho the best option is to use the v-model on the slotted component and let Vue do the heavy lifting for you...BUT

    ..normally when you are creating an input (or some custom input) wrapper, easiest thing to do is to use computed to "connect" (or forward) inner component's v-model to a v-model of the wrapper:

    <template>
      <input :type="type" v-model="model" />
    </template>
    <script>
    export default {
      props: ['value', 'type'],
      computed:{
        model: {
          get() { return this.value }
          set(newValue) { this.$emit('input', newValue) } 
        }
      }
    }
    </script>
    

    Why ? For exactly the same reason you are asking your question. You do not need to choose which prop and event use (because different input types use a different prop name for a value and use different event) - you can just leave it to a v-model

    But to do same thing on slotted component is a bit tricky. You can't place a v-model on <slot> directly. It must be placed on the slotted component (in parent's template). So only way is to pass "something" like the computed above into a slot props. That is problem too.

    1. slot props cannot be mutated by slot content in the same way normal props should not be mutated from inside the component
    2. it is not possible to v-bind computed as a whole to a slot. If you try that, the slotted component only receives the current value read from the getter and setter is left behind...

    To overcome the first problem, same technique as with normal props can be used - instead of passing just a value, pass an object and make the value its property. Now you can mutate the value of that property as you wish (this is considered by many as dirty but I think it is very powerful and can save you a lot of boilerplate code if used wisely)

    Second problem can be solved by using Object.defineProperty API which allows you to do something similar as Vue computed properties - define a property of an object and declare your own functions to be used when the property is read or written..

    Here is a final solution:

    // InputWrapper.vue
    <template>
      <div>
        <slot v-bind="wrappedBindings"></slot>
      </div>
    </template>
    
    <script>
    export default {
      props: {
        value: { required: true },
      },
    
      computed: {
        wrappedBindings() {
          const self = this;
          const bindings = {
            attrs: {
            },
            on: {
            },
            model: {},
          };
    
          // property MUST be on 2nd level otherwise it wont work thanks to how Vue 2 is implemented
          // https://github.com/vuejs/vue/blob/0948d999f2fddf9f90991956493f976273c5da1f/src/core/instance/render-helpers/render-slot.js#L24
          // https://github.com/vuejs/vue/blob/0948d999f2fddf9f90991956493f976273c5da1f/src/shared/util.js#L205
          Object.defineProperty(bindings.model, "proxy", {
            get() {
              return self.value;
            },
            set(newValue) {
              self.$emit("input", newValue);
            },
            enumerable: true,
            configurable: false,
          });
    
          return bindings;
        },
      },
    };
    </script>
    

    And the usage:

    <input-wrapper v-model="inputModel" v-slot="{ attrs, on, model }">
      <b-form-input
        v-bind="attrs"
        v-on="on"
        v-model="model.proxy"
      ></b-form-input>
    </input-wrapper>
    

    This is of course not ideal (especially the fact that you need to use model.proxy in the v-model) but right now I do not see any other way how to implement such "universal" wrapper (except of course to choose a component library that sticks to a "value"/"input" for a v-model on custom components)

    Demo