Skip to content

A Form Component in Vue.js – Making nice wrappers

One excellent way to save time in Vue.js is to create reusable form components. These components will wrap native HTML5 elements, but add all the options we might need. For this post I’m going to go over one strategy with the <input type="text" /> element.

Here are my goals in this post:

  1. Wrap components so that you may call and bind things to these elements like usual (and use v-model!)
  2. Make the component have an associated label
  3. Handle input type options and other listeners

Let’s do this.

Vue.js logo - we will be focusing on vue components in this post

Wrapping components

If you were to begin making a Text-Input Vue component you might begin like this:

<template>
  <input type="text" />
</template>

<script>
export default {
  name: "TextInput",
};
</script>

Now, that’s all good. But we don’t have a way to bind values to that input or to catch text changes inside the input. We basically want to be able to use v-model with this guy.

<!-- Right now we would call it in a template like this: -->
<TextInput v-model="text"/>
<!-- but Vue doesn't know what to do with that v-model yet! -->

So how does v-model work? As the Vue.js docs say, “v-model is essentially syntax sugar for updating data on user input events.” What this means is that using v-model is the same as binding a value to the component and receiving an @input event from it. Maybe this is clearer…

<input
    type="text"
    v-model="someValue"
/>

<!-- is the same as... -->

<input
    type="text"
    :value="someValue"
    @input="updateNewTypedValue"
/>

But that’s kind of psuedo code… so let’s actually write out a working component.

<template>
  <input 
    type="text"
    :value="value"
    @input="handleInput"
  />
</template>

<script>
export default {
  name: "TextInput",
  props: {
    value: String
  },
  methods: {
    handleInput(event) {
      this.$emit("input", event.target.value);
    }
  }
};
</script>

Notice that the handleInput method sends up an @input event with the new, typed value. That’s how v-model expects values to go back and forth.

Now, when we use the TextInput component, we can just use v-model!

Making a label

Alright, the reason we’re making this form component is so we can encapsulate some functionality and reuse the bujeebers out of it. We probably want to make sure all of our inputs have a label that goes with it.

We also need to make sure that label is tied to the input so screen readers know what it’s labelling. We’re going to make the TextInput component receive a label attribute that gives the label some text. While we’re at it, we’re going to also accept an id to uniquely identify this input and tie it to the label.

<template>
  <div>
    <label :for="id">{{label}}</label>
    <input :id="id" :name="id" type="text" :value="value" @input="handleInput">
  </div>
</template>

<script>
export default {
  name: "TextInput",
  props: {
    id: String,
    value: String,
    label: String
  },
  methods: {
    handleInput(event) {
      this.$emit("input", event.target.value);
    }
  }
};
</script>

<style lang="scss" scoped>
label {
  display: block;
}
</style>

Oh yeah, and to force the input to the next line I made the label to have a display of block.

Here’s where we are so far:

What about other attributes and options?

So far we this component default to type="text". What if we wanted to add other attributes to this? Or what if we wanted to change the type to password?

Let’s handle the passing of random attributes down to the input box. As Vue 2.x stands right now, any attributes added to <TextInput /> will be automatically added to the top-most container in the template of that component. We want our component to take those attributes and add them to the text input. So, if we add a random attribute, we can pass it along to the input element itself.

<template>
  <div>
    <label :for="id">{{label}}</label>
    <input :id="id" :name="id" type="text" :value="value" @input="handleInput" v-bind="$attrs">
  </div>
</template>

<script>
export default {
  name: "TextInput",
  inheritAttrs: false, // This stops the component containing element from automatically receiving attrs
  props: {
    id: String,
    value: String,
    label: String
  },
  methods: {
    handleInput(event) {
      this.$emit("input", event.target.value);
    }
  }
};
</script>

<style lang="scss" scoped>
label {
  display: block;
}
</style>

One caveat here: styles and classes will always be passed on to the parent container. Check out the api docs here. If we need to pass specific classes or styles down to only the input or label, we should make up some props to do that.

But anywho, we also need to make sure that if a person wanted to bind event listeners to this component he still could. So we need to pass $listeners on through as well!

<input
  :id="id"
  :name="id"
  type="text"
  :value="value"
  @input="handleInput"
  v-bind="$attrs"
  v-on="$listeners"
>

Now, if you add an additional @focus listener, say, to our component, it will work! But we have to fix one thing. Right now the v-on will overwrite our “input” listener, so we need to make sure that we manually take that out before we use v-on:

<template>
  <div>
    <label :for="id">{{label}}</label>
    <input
      :id="id"
      :name="id"
      type="text"
      :value="value"
      @input="handleInput"
      v-bind="$attrs"
      v-on="getListeners"
    >
  </div>
</template>

<script>
export default {
  name: "TextInput",
  inheritAttrs: false,
  props: {
    id: String,
    value: String,
    label: String,
  },
  computed: {
    getListeners() {
      const { input, ...others } = this.$listeners;
      return { ...others };
    }
  },
  methods: {
    handleInput(event) {
      console.log(event);
      this.$emit("input", event.target.value);
    }
  }
};
</script>

<style lang="scss" scoped>
label {
  display: block;
}
</style>

The last option we may want to include is changing the type. What if we wanted this text input to actually be a password type?

In the component I’m going to set up a prop type that allows us to specify password, number, etc. but it will always default to text. (And we are also assuming someone won’t come along at set the type to be something like “submit” or “button”. But I don’t want to go down that path right now…)

<template>
  <div>
    <label :for="id">{{label}}</label>
    <input
      :id="id"
      :name="id"
      :type="type ? type : 'text'"
      :value="value"
      @input="handleInput"
      v-bind="$attrs"
      v-on="getListeners"
    >
  </div>
</template>

<script>
export default {
  name: "TextInput",
  inheritAttrs: false,
  props: {
    id: String,
    value: String,
    label: String,
    type: String
  },
  computed: {
    getListeners() {
      const { input, ...others } = this.$listeners;
      return { ...others };
    }
  },
  methods: {
    handleInput(event) {
      console.log(event);
      this.$emit("input", event.target.value);
    }
  }
};
</script>

<style lang="scss" scoped>
label {
  display: block;
}
</style>

That’s all for now

I hope this was helpful to get you started toward making a reusable Vue form component. There are still a lot of things you can do yet- adding validation and validation styles are a big one. But I’m going to reserve that for my next post 🙂

As always, let me know what you think!

Leave a Reply

Your email address will not be published. Required fields are marked *