Series: Building reusable custom components with Vue.js

Series: Building reusable custom components with Vue.js

Part 2: Basic drop-down and v-model

September 8, 2017

Posts in this series

Intro

For our demonstration we want to allow a user to select a single fruit from a list of available fruits. Later on we will be improving it with autocomplete behavior. In this post we’ll start with a naive select-based filter and show how to convert that to a fully encapsulated Dropdown component.

We will start with a parent component, Filters.vue which will hold our various filter implementations and indicate what the currently selected fruit is:

<template>
  <div class="filters">

    <!-- Our various filter implementations will go here -->

    <div class="result">
      Selected: <strong>{{ selectedFruit }}</strong>
    </div>
  </div>
</template>

<script>
export default {
  data () {
    return {
      selectedFruit: 'Apple'
    }
  }
}
</script>

<style scoped>

.filters {
  width: 800px;
  margin: 0 auto;
}

.filter {
  text-align: left;
}

.result {
  margin-top: 30px;
  text-align: left;
}

label {
  display: block;
}

</style>

Naive filter

The simplest approach for letting a user select a fruit is to add a select to our template and set the v-model attribute to selectedFruit.

<div class="filter">
  <label for="basic-dropdown">Basic dropdown: </label>
  <select name="basic-dropdown" v-model="selectedFruit">
    <option>Apple</option>
    <option>Banana</option>
    <option>Blueberry</option>
    <option>Kiwi</option>
    <option>Pear</option>
    <option>Pineapple</option>
    <option>Watermelon</option>
  </select>
</div>

This does the trick and may be all that is required for a given use-case.

However, if we begin to enhance the behavior of this drop-down we will find that we are filling up the parent component Filters with drop-down-specific code. Additionally, if we want the same behavior with a different drop-down and a different set of options we will run into duplication of code.

Our goal is to re-factor this drop-down in a DRY way so that over time we can improve the behavior of the drop-down without having to constantly rewrite the behavior of any parent component which includes it.

We’ll start by capturing the existing implementation as a stand-alone component.

Basic setup

Let’s start with an empty Dropdown.vue file and add just the select element and options to the file:

<template>
  <div>
    <select>
      <option>Apple</option>
      <option>Banana</option>
      <option>Blueberry</option>
      <option>Kiwi</option>
      <option>Pear</option>
      <option>Pineapple</option>
      <option>Watermelon</option>
    </select>
  </div>
</template>

<script>

export default {
}

</script>

Custom options

Since we want our component to be generic, we need to factor out the options into a prop. The available options will be provided to the Dropdown component by its parent component.

The structure of the options prop will be an object where keys are unique human-readable strings which will be used as the text of the option. Values can be almost anything. Vue.js supports binding arbitrary objects as selected values in a drop-down.

The Project filter I mentioned in Part 1 used fully populated Project objects from the Teamwork API as values with the project name as the string-key.

In our case the values will be strings which exactly match the fruit names.

In Dropdown.vue we add our options prop:

<script>

export default {
  props: {
    options: {
      type: Object,
      required: true      
    }
  }
}

</script>

In the template we use v-for to loop over and render the options:

<template>
  <div>
    <select>
      <option v-for="(option, name) in options" :value="option">{{ name }}</option>
    </select>
  </div>
</template>

Note the use of the object key syntax for v-for.

We can now add the component to the Filters.vue template:

    <div class="filter">
      <label for="component-dropdown">Component-based dropdown: </label>
      <dropdown id="component-dropdown" :options="fruitOptions"></dropdown>
    </div>

and specify the options:

<script>

import Dropdown from '@/components/Dropdown'

export default {
  components: {
    'dropdown': Dropdown
  },

  data () {
    return {
      selectedFruit: 'Apple',
      fruitOptions: {
        'Apple': 'Apple',
        'Banana': 'Banana',
        'Blueberry': 'Blueberry',
        'Kiwi': 'Kiwi',
        'Pear': 'Pear',
        'Pineapple': 'Pineapple',
        'Watermelon': 'Watermelon'
      }
    }
  }
}

</script>

Implement v-model

We can now see our select but it doesn’t support reactively updating the selected value when the drop-down value is changed. It is straightforward to add support for v-model binding in a custom Vue.js component.

All your component needs to do is:

  • Provide a “value” prop which is used by the parent to set the value of your component
  • Emit an “input” event when the value of your component changes

However, the specifics of this can trip you up, particularly when you are starting simple and expanding to a more complex implementation.

There are some general principles which will help to avoid pitfalls:

  1. Determine how your component’s current value will be represented (e.g. string, number, custom object).
  2. emit an “input” event when the selected value changes (how and when you do this may change depending on the details of your component).
  3. Provide a value prop which accepts valid values for your component.
  4. Ensure that the controls in your component reflect this value when mounted and when the value of the prop changes.

Making sure you follow the above principles will give you correct behavior for simple cases like our drop-down as well as the more complex cases we will see later.

For our drop-down, the current value can be pretty much anything since the options are provided by the parent. We’ll keep that in mind as we continue.

The only time our component’s value changes is when the select element changes. We can handle #2 with a single event listener bound to the select’s @input event:

<template>
  <div class="dropdown">
    <select
      @input="event => { $emit('input', event.target.value) }"
      >
      <option v-for="(option, name) in options" :value="option">{{ name }}</option>
    </select>
  </div>
</template>

Adding the prop for #3 is simple:

  props: {
    value: null,
    options: {
      type: Object,
      required: true
    }
  },

In our implementation the parent component is responsible for setting value and for providing the set of valid options. We set the prop to null which means any valid type is supported.

If you are implementing v-model support in a component that is responsible for its own options you would likely be more restrictive with this property definition and potentially include a validation callback here.

Finally, we need to make sure that the select element updates to reflect the value prop when it changes. The simplest way to do this is to create a data property to represent the currently selected value:

  data () {
    return {
      selectedOption: null
    }
  },

and use v-model to bind it to the select:

<template>
  <div class="dropdown">
    <select
      v-model="selectedOption"
      @input="event => { $emit('input', event.target.value) }"
      >
      <option v-for="(option, name) in options" :value="option">{{ name }}</option>
    </select>
  </div>
</template>

With that in place we can handle #4 by setting selectedOption in a mounted() method and in a watcher for the value prop:

mounted () {
  this.selectedOption = this.value
},

watch: {
  value: function (newValue) {
    this.selectedOption = newValue
  }
}

You might be wondering at this point why we didn’t just v-model the select element to the value. One reason, is that doing this will cause value to be modified whenever the select is changed. Vue.js will throw a warning when this occurs. The value prop should never be modified directly. You signal value changes to the parent by emitting the input event.

The other reason not to v-model the value prop is that your component’s internal representation will likely be different from the representation which is used in the value prop. You need to have the ability to transform those representations and using v-model in this way couples those values too closely together. In fact, when we add autocomplete later on we will remove the select and the v-model attribute entirely.

With the above in place we can now add the v-model directive to our component in Filters.vue:

<div class="filter">
  <label for="component-dropdown">Component-based dropdown: </label>
  <dropdown id="component-dropdown" :options="fruitOptions" v-model="selectedFruit"></dropdown>
</div>

You should be able to change the value of selectedFruit by changing either drop-down and both drop-downs should update to reflect the currently selected value.

We are now in a great position to enhance this component with autocomplete functionality.

Next post in series: Autocomplete dropdown

Follow our RSS or Twitter feed to be notified as soon as it's posted.


Comments

Small suggestion, you can eliminate the need for your mounted lifecycle hook with the watch immediate setting by doing something like the following (formatting is being lost):

watch: {
value: {
immediate: true,
handler: function (newValue) {
this.selectedOption = newValue
}
}
}

Add new comment