Composition APIでTypeScriptを使いv-modelで扱える簡易なフォームコンポーネントを作る

Composition APIでTypeScriptを使って、フォームを作るサンプルコードを書いた。
フォームのコンポーネントとしてinput[type=text]、textarea、checkbox、radio、selectの簡易なものを用意した。
フォームのコンポーネントは親コンポーネントと子コンポーネント間のデータのやりとりがハマりどころだ。だからv-modelが使えることを確認した。
コンポーネント間でデータのやり取りが出来てさえしまえば後は項目を追加するだけなので、idやname、disabled、placeholderなどその他の項目は書いていない。

親コンポーネントから以下のように使えるコンポーネントを作っていく。
NuxtJSで試しているけど、vuejs/composition-apiでも変わらないと思う。

<template>
  <div>
    <div>
      {{ form }}
    </div>
    <MyInput v-model="form.text" />
    <MyTextarea v-model="form.longText" />
    <MyCheckbox v-model="form.checked">check</MyCheckbox>
    <MyRadio v-model="form.picked" label="one">One</MyRadio>
    <MyRadio v-model="form.picked" label="two">Two</MyRadio>
    <MySelect v-model="form.selected" :options="options" />
  </div>
</template>

<script lang="ts">
import { defineComponent, reactive } from '@nuxtjs/composition-api'

interface State {
  text: string
  longText: string
  checked: boolean
  picked: string
  selected: string
}

export default defineComponent({
  setup() {
    const form = reactive<State>({
      text: 'init text',
      longText: 'init long text',
      checked: false,
      picked: 'two',
      selected: 'b',
    })

    const options = [
      { label: 'A', value: 'a' },
      { label: 'B', value: 'b' },
      { label: 'C', value: 'c' },
    ]

    return {
      form,
      options,
    }
  },
})
</script>

Input

inputタグでtype="text"の場合、props名valueで値を渡し、inputイベントで親コンポーネントへ変更を知らせる。
event.targetHTMLInputElementとして扱うことでvalueを取得できる。

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

<script lang="ts">
import { defineComponent } from '@nuxtjs/composition-api'

export default defineComponent({
  props: {
    value: {
      type: [String, Number],
      default: '',
    },
  },
  emits: ['input'],
  setup(_, ctx) {
    const handleInput = (e: Event) => {
      const target = e.target as HTMLInputElement
      ctx.emit('input', target.value)
    }

    return {
      handleInput,
    }
  },
})
</script>

Textarea

textareaタグはinputタグでtype="text"と同様に、props名valueで値を渡し、inputイベントで親コンポーネントへ変更を知らせる。
event.targetHTMLTextAreaElementとして扱うことでvalueを取得できる。

<template>
  <textarea :value="value" @input="handleInput"></textarea>
</template>

<script lang="ts">
import { defineComponent } from '@nuxtjs/composition-api'

export default defineComponent({
  props: {
    value: {
      type: [String, Number],
      default: '',
    },
  },
  emits: ['input'],
  setup(_, ctx) {
    const handleInput = (e: Event) => {
      const target = e.target as HTMLTextAreaElement
      ctx.emit('input', target.value)
    }

    return {
      handleInput,
    }
  },
})
</script>

Checkbox

inputタグでtype="checkbox"の場合、props名checkedで値を渡し、changeイベントで親コンポーネントへ変更を知らせるようにした。
event.targetHTMLInputElementとして扱うことでvalueを取得できる。

自作のコンポーネントは、デフォルトではprops名はvalue、親コンポーネントへinputイベントで変更を知らせようとする。

デフォルトではコンポーネントにある v-model は value をプロパティとして、input をイベントして使います https://jp.vuejs.org/v2/guide/components-custom-events.html#v-model-を使ったコンポーネントのカスタマイズ (opens new window)

props名と親コンポーネントへ変更を知らせるイベントを変更したい場合、modelオプションを使う。

  model: {
    prop: 'checked',
    event: 'change',
  },

イベント名は変更したほうがハマりづらいのかなと思う。イベント名を変更しない場合、子コンポーネントで@changeを使っているにも関わらず、親コンポーネントでは@inputでイベントを受けないといけないからだ。

<template>
  <label>
    <input :checked="checked" type="checkbox" @change="handleChange" />
    <slot></slot>
  </label>
</template>

<script lang="ts">
import { defineComponent } from '@nuxtjs/composition-api'

export default defineComponent({
  model: {
    prop: 'checked',
    event: 'change',
  },
  props: {
    checked: {
      type: Boolean,
      default: false,
    },
  },
  emits: ['change'],
  setup(_, ctx) {
    const handleChange = (e: Event) => {
      const target = e.target as HTMLInputElement
      ctx.emit('change', target.checked)
    }

    return {
      handleChange,
    }
  },
})
</script>

Radio

radioではlabelpropで選択肢の値を渡して、valueで選択した値を渡すようにしている。checkboxも同様の仕組みにすればBoolean以外も扱える。

<template>
  <label>
    <input
      :value="label"
      :checked="label === value"
      type="radio"
      @change="handleChange"
    />
    <slot></slot>
  </label>
</template>

<script lang="ts">
import { defineComponent } from '@nuxtjs/composition-api'

export default defineComponent({
  model: {
    event: 'change',
  },
  props: {
    value: {
      type: [Boolean, String, Number],
      default: '',
    },
    label: {
      type: [Boolean, String, Number],
      default: '',
    },
  },
  emits: ['change'],
  setup(_, ctx) {
    const handleChange = (e: Event) => {
      const target = e.target as HTMLInputElement
      ctx.emit('change', target.value)
    }

    return {
      handleChange,
    }
  },
})
</script>

Select

selectタグはprops名valueで値を渡し、changeイベントで親コンポーネントへ変更を知らせている。
event.targetHTMLSelectElementとして扱うことで、選択したオプションselectedOptionsを取得できる。

<template>
  <select @change="handleChange">
    <option
      v-for="(option, index) in options"
      :key="index"
      :value="option.value"
      :selected="value === option.value"
    >
      {{ option.label }}
    </option>
  </select>
</template>

<script lang="ts">
import { defineComponent, PropType } from '@nuxtjs/composition-api'

interface LavelValue {
  lavel: string | number
  value: string | number
}

export default defineComponent({
  model: {
    event: 'change',
  },
  props: {
    value: { 
      type: [String, Number],
      default: '',
    },
    options: { 
      type: Array as PropType<LavelValue[]>, 
      default: () => [],
    },
  },
  emits: ['change'],
  setup(_, ctx) {
    const handleChange = (e: Event) => {
      const target = e.target as HTMLSelectElement
      ctx.emit('change', target.selectedOptions[0].value)
    }

    return {
      handleChange,
    }
  },
})
</script>

参考

https://jp.vuejs.org/v2/guide/forms.html (opens new window)
https://element-plus.org/ (opens new window)
https://qiita.com/wakame_isono_/items/611e51ff965d698bbc7c (opens new window)

関連記事

Nuxt Composition APIでv2との書き方の違いを確認した (opens new window)
Vue.jsのinputイベントがselectタグで発火しない(Internet Explorer 11)原因と対応 (opens new window)