Vue.jsでForm部品をComponent化する

# はじめに
ゆるいAtomicDesignを意識してForm部品をComponent化してみました。

# Input

```js:input.vue
<template>
  <label>
    <span v-if="$slots.label"><slot name="label"></slot></span>
    <span v-else-if="label">{{ label }}</span>

    <input :type="type"
           :name="name"
           :placeholder="placeholder"
           :value="value"
           @input="updateValue"
           @focus="$emit('focus', $event)"
           @blur="$emit('blur', $event)"
    >
  </label>
</template>
<script>
  export default {
    props: {
      value: {},
      label: { type: String },
      name: { type: String, require: true },
      type: { type: String, default: 'text' },
      placeholder: { type: String }
    },

    methods: {
      updateValue (e) {
        this.$emit('input', e.target.value)
        this.$emit('change', e.target.value)
      }
    }
  }
</script>
```

グローバル登録

```js
import Input from '@/components/input'
Vue.component('vue-input', Input)
```

呼び出し

```html
<vue-input label="Input" name="input" v-model="form.input"></vue-input>
```

propされた値はComponent内で直接更新できないのでv-modelの糖衣構文(v-bind:value、v-on:input)を利用します。

v-bind:value

```js
<input ...省略
       :value="value"
       @input="updateValue"
       ...省略
>
```

v-on:input

```js
updateValue (e) {
  this.$emit('input', e.target.value)
  this.$emit('change', e.target.value)  // Changeイベント
}
```

値が変更された場合に処理を行うためにChangeイベントもトリガさせています。

```html
<vue-input label="Input" name="input"
           v-model="form.input"
           @change="handleChange" <!-- 値が変更された場合の処理 -->
></vue-input>
```

label部分は文字列以外の場合もあるので、slotまたはpropでコンテンツを渡します。

slot

```html
<vue-input name="input" v-model="form.input">
　　<template slot="label"> <!-- slotでlabelを渡す -->
　　　　<span>*</span>Label
　　</template>
</vue-input>
```

prop

```html
<vue-input label="Input" name="input" v-model="form.input"></vue-input>
```

# Radio
```js:radio.vue
<template>
  <label>
    <span v-if="$slots.label"><slot name="label"></slot></span>
    <span v-else-if="label">{{ label }}</span>

    <input type="radio"
           :name="name"
           :value="value"
           :checked="checked === value"
           @change="updateValue"
           @focus="$emit('focus', $event)"
           @blur="$emit('blur', $event)"
    >
    <slot></slot>
  </label>
</template>
<script>
  export default {
    model: {
      prop: 'checked',
      event: 'input'
    },

    props: {
      value: {},
      checked: {},
      label: { type: String },
      name: { type: String, require: true }
    },

    methods: {
      updateValue (e) {
        this.$emit('input', this.value)
        this.$emit('checked', this.value)
      }
    }
  }
</script>
```

グローバル登録

```js
import Radio from '@/components/radio'
Vue.component('vue-radio', Radio)
```

呼び出し

```html
<vue-radio label="Radio" name="radio1" :value="1" v-model="form.radio1">1</vue-radio>
```

Radioにはvalue属性があり、v-bind:valueに競合するため、modelを使ってv-bind:valueをv-bind:checkedに変更します。

```js
model: {
  prop: 'checked', // v-bind:checked
  event: 'input' // v-on:input
}
```

## Radio Group

複数のRadioを扱いたい場合があるかもしれません。
その場合はグループ用のComponentを利用します。

```js:group.vue
<template>
  <div class="vue-group">
    <label v-if="$slots.label"><slot name="label"></slot></label>
    <label v-else-if="label">{{ label }}</label>

    <slot></slot>
  </div>
</template>
<script>
  export default {
    props: {
      value: {},
      label: { type: String }
    }
  }
</script>
```

Radio Componentを変更します。

```js
<template>
  <label>
    <span v-if="$slots.label"><slot name="label"></slot></span>
    <span v-else-if="label">{{ label }}</span>

    <input type="radio"
           :name="name"
           :value="value"
           :checked="input === value"
           @change="updateValue"
           @focus="$emit('focus', $event)"
           @blur="$emit('blur', $event)"
    >
    <slot></slot>
  </label>
</template>
<script>
  export default {
    model: {
      prop: 'checked',
      event: 'input'
    },

    props: {
      value: {},
      checked: {},
      label: { type: String },
      name: { type: String, require: true }
    },

    computed: {
      group () {
        return ('$parent' in this && this.$parent.$options.name === 'vue-group') ? this.$parent : null
      },
      input () {
        return this.group ? this.group.value : this.checked
      }
    },

    methods: {
      updateValue (e) {
        if (this.group) {
          this.group.$emit('input', this.value)
          this.group.$emit('checked', this.value)
        } else {
          this.$emit('input', this.value)
          this.$emit('checked', this.value)
        }
      }
    }
  }
</script>
```

グローバル登録

```js
import Radio from '@/components/radio'
import Radio from '@/components/radio'
Vue.component('vue-radio', Radio)
Vue.component('vue-group', Group)
```

呼び出し

```html
<vue-group label="Radio" v-model="form.radio2">
  <vue-radio name="radio2" :value="1">1</vue-radio>
  <vue-radio name="radio2" :value="2">2</vue-radio>
</vue-group>
```

this.$parentが存在し、それがGroup ComponentならばGroup Componentのv-modelを利用します。

# CheckBox

CheckBoxはRaidoとほぼ同様です。

```js:check.vue
<template>
  <label>
    <span v-if="$slots.label"><slot name="label"></slot></span>
    <span v-else-if="label">{{ label }}</span>

    <input type="checkbox"
           :name="name"
           :value="value"
           :checked="input === value"
           @change="updateValue"
           @focus="$emit('focus', $event)"
           @blur="$emit('blur', $event)"
    >
    <slot></slot>
  </label>
</template>
<script>
  export default {
    model: {
      prop: 'checked',
      event: 'input'
    },

    props: {
      value: {},
      checked: {},
      label: { type: String },
      name: { type: String, require: true }
    },

    computed: {
      group () {
        return ('$parent' in this && this.$parent.$options.name === 'vue-group') ? this.$parent : null
      },
      input () {
        return this.group ? this.group.value : this.checked
      }
    },

    methods: {
      updateValue (e) {
        let value = e.target.checked ? this.value : null
        if (this.group) {
          this.group.$emit('input', value)
          this.group.$emit('checked', value)
        } else {
          this.$emit('input', value)
          this.$emit('checked', value)
        }
      }
    }
  }
</script>
```

グローバル登録

```
import Check from '@/components/check'
Vue.component('vue-check', Check)
```

呼び出し

```html
<vue-check label="Check" name="check" :value="1" v-model="form.check">1</vue-check>
```

# Select
```js:select.vue
<template>
  <label>
    <span v-if="$slots.label"><slot name="label"></slot></span>
    <span v-else-if="label">{{ label }}</span>

    <select :name="name"
            :value="value"
            @input="updateValue"
            @focus="$emit('focus', $event)"
            @blur="$emit('blur', $event)"
    >
      <slot :options="options"></slot>
    </select>
  </label>
</template>
<script>
  export default {
    props: {
      value: {},
      label: { type: String },
      name: { type: String, require: true },
      options: { type: Array, required: true }
    },

    methods: {
      updateValue (e) {
        let value = e.target.selectedOptions.length ? e.target.selectedOptions[0]._value : null
        this.$emit('input', value)
        this.$emit('selected', value)
      }
    }
  }
</script>
```

呼び出し

```html
<vue-select label="Select" name="select" v-model="form.select" :options="options">
  <template slot-scope="props">
    <option v-for="(option, key) in props.options"
            :key="key"
            :value="option.id"
            :selected="form.select === option.id"
     >{{ option.value }}</option>
  </template>
</vue-select>
```

非同期でoptionsを読み込み糖衣構文を利用すると、ｖ−modelで指定したoptionが選択状態にならないので、selectedを利用して選択状態にします。

```html
<option ...省略
        :selected="form.select === option.id"
>{{ option.value }}</option>
```

選択したoptionのvalueはString型になるので元々の型がbindされている_valueを利用します。

```js
e.target.selectedOptions[0]._value // e.target.selectedOptions[0].valueはString型
```

最後に、main.jsとapp.vueです。

# main.js

```js:main.js
// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue'
import App from '@/app'

import Input from '@/components/input'
import Check from '@/components/check'
import Radio from '@/components/radio'
import Select from '@/components/select'
import Group from '@/components/group'
Vue.component('vue-input', Input)
Vue.component('vue-check', Check)
Vue.component('vue-radio', Radio)
Vue.component('vue-select', Select)
Vue.component('vue-group', Group)

Vue.config.productionTip = false

/* eslint-disable no-new */
new Vue({
  el: '#app',
  template: '<App/>',
  components: { App }
})
```

# app.vue
```js:app.vue
<template>
  <div id="app">
    <form @submit.prevent="onSubmit">
      <div class="form-item">
        <vue-input label="Input" name="input" v-model="form.input"></vue-input>
        - {{ form.input }}
      </div>
      <div class="form-item">
        <vue-radio name="radio1" :value="1" v-model="form.radio1">1</vue-radio>
        <vue-radio name="radio1" :value="2" v-model="form.radio1">2</vue-radio>
        - {{ form.radio1 }}
      </div>
      <div class="form-item">
        <vue-group label="Radio" v-model="form.radio2">
          <vue-radio name="radio2" :value="1">1</vue-radio>
          <vue-radio name="radio2" :value="2">2</vue-radio>
        </vue-group>
        - {{ form.radio2 }}
      </div>
      <div class="form-item">
        <vue-check label="Check" name="check" :value="1" v-model="form.check">1</vue-check>
        - {{ form.check }}
      </div>
      <div class="form-item">
        <vue-select label="Select" name="select" v-model="form.select" :options="options">
          <template slot-scope="props">
            <option v-for="(option, key) in props.options"
                    :key="key"
                    :value="option.id"
                    :selected="form.select === option.id"
            >{{ option.value }}</option>
          </template>
        </vue-select>
        - {{ form.select }}
      </div>
      <div class="form-item">
        <button type="submit">送信</button>
      </div>
    </form>
  </div>
</template>

<script>
  export default {
    name: 'app',

    data: () => ({
      form: {
        input: 'test',
        radio1: 1,
        radio2: 1,
        check: 1,
        select: 3
      },
      options: [
        { id: 1, value: 'option1' },
        { id: 2, value: 'option2' },
        { id: 3, value: 'option3' },
        { id: 4, value: 'option4' }
      ]
    }),

    methods: {
      onSubmit () {
        console.log('送信')
      }
    }
  }
</script>
```

# 完成品

![advent calendar.png](https://qiita-image-store.s3.amazonaws.com/0/157109/967a556e-f021-c55c-e200-05ee3d84982a.png)

# まとめ

Vue.jsでForm部品をComponent化してみました。
[Element](http://element.eleme.io/#/en-US)を参考にしています。