vue3 + typescript環境でformをComponent化する

##

[Vue.jsでFormの各要素をComponent化する際の覚え書き](https://qiita.com/ryo2132/items/2e3fcedaffeff9fc3967) - [@yo2132](https://qiita.com/ryo2132)

[@yo2132](https://qiita.com/ryo2132)さんの記事が素晴らしいと思ったと同時に、自分用のTS記事も欲しかったので記載。

間違っていたらコメント、issueお願いします。

[vue3-ts-sample-form - github](https://github.com/taka1156/vue3-ts-sample-form)

## 補足

**補足1: vue3からv-modelが複数使えるようになったからかv-modelを使う際に次のようにする必要があるみたい？**

```vue
<template>
  <hoge
   v-model:value="state.hoge"
  />
</template>
```

詳しくはコチラ
[Vue3でv-modelがどう変わったか](https://qiita.com/ryo-gk/items/48346f3d9eb2b8169f48) - [@ryo-gk](https://qiita.com/ryo-gk)

---

**補足2: バリデーションに[vuelidate-next](https://vuelidate-next.netlify.app/)を使用**
[vuelidate/vuelidate - github](https://github.com/vuelidate/vuelidate)

---

## 修正
**修正 2021/2/24: なぜか、radioフォームにonMountedがついてたので削除**

元々の記事ではselectフォームについていますが、今回のは、'選択してください'と言うdisabledなものを初期選択としてるので必要ないはず

(値が空になりますがその場合は、親コンポーネントでバリデーションすることを想定)

## 環境
Vite.js 誤: ~~3.0.5~~ 正: 2.0.1
TypeScript 4.1.3
@vitejs/plugin-vue 1.1.4
@vue/compiler-sfc 3.0.5

たぶん、vue-cliでも使える?

## コンポーネント一覧


### BaseErrors(バリデーションエラーを表示)
```vue:BaseErrors.vue

<template>
  <div v-if="errors.length !== 0">
    <p
      class="base-errors"
      v-for="error in errors"
      :key="error.$message"
    >
      {{ error.$message }}
    </p>
  </div>
</template>

<script lang="ts">
import { defineComponent, PropType } from 'vue';

export default defineComponent({
  name: 'BaseErrors',
  props: {
    errors: {
      type: Array as PropType<ValidateError[]>,
      default: () => []
    }
  }
});
</script>

<style scoped>
p {
  margin: 0;
  padding: 0;
}
.base-errors {
  color: red;
}
</style>

```

---
### BaseLabel
```vue:BaseLabel.vue
<template>
  <label :for="id">
    <slot />
  </label>
</template>

<script lang="ts">
import { defineComponent, PropType } from 'vue';

export default defineComponent({
  name: 'BaseLabel',
  props: {
    id: {
      type: String as PropType<string>,
      required: true
    }
  },
  setup(props) {
    return {
      props
    };
  }
});
</script>

<style scoped></style>
```

***
### BaseInput
```vue:BaseInput.vue
<template>
  <input
    :id="id"
    :name="name"
    :type="type"
    :value="value"
    :placeholder="placeholder"
    @input="updateValue"
  />
</template>

<script lang="ts">
import { defineComponent, PropType, SetupContext } from 'vue';

// fileやcolorはUIが違いすぎるので別枠で作成
// もしくはカラーピッカーなど別で作ったほうが見栄えがいいものは除外(場合によっては、time、dateも分ける？)
type InputAttr =
  | 'text'
  | 'number'
  | 'email'
  | 'url'
  | 'password'
  | 'tel'
  | 'date'
  | 'time';

export default defineComponent({
  name: 'BaseInput',
  props: {
    id: {
      type: String as PropType<string>,
      required: true
    },
    name: {
      type: String as PropType<string>,
      required: true
    },
    type: {
      type: String as PropType<InputAttr>,
      required: true
    },
    value: {
      type: String as PropType<string>,
      required: true
    },
    placeholder: {
      type: String as PropType<string>,
      required: true
    }
  },
  setup(props, context: SetupContext) {
    const updateValue = (e: Event) => {
      if (e.target instanceof HTMLInputElement) {
        context.emit('update:value', e.target.value);
      }
    };

    return {
      props,
      updateValue
    };
  }
});
</script>

<style scoped>
input {
  width: 100%;
}
</style>

```

***
### BaseTextArea
```vue:BaseTextArea.vue
<template>
  <textarea
    :id="id"
    :name="name"
    :placeholder="placeholder"
    :rows="rows"
    :cols="cols"
    :value="value"
    @input="updateValue"
  ></textarea>
</template>

<script lang="ts">
import { defineComponent, SetupContext, PropType } from 'vue';

export default defineComponent({
  name: 'BaseTextArea',
  props: {
    id: {
      type: String as PropType<string>,
      required: true
    },
    name: {
      type: String as PropType<string>,
      required: true
    },
    value: {
      type: String as PropType<string>,
      required: true
    },
    rows: {
      type: Number as PropType<number>,
      required: true
    },
    cols: {
      type: Number as PropType<number>,
      required: true
    },
    placeholder: {
      type: String as PropType<string>,
      required: true
    }
  },
  setup(props, context: SetupContext) {
    const updateValue = (e: Event) => {
      if (e.target instanceof HTMLTextAreaElement) {
        context.emit('update:value', e.target.value);
      }
    };
    return { props, updateValue };
  }
});
</script>

<style scoped></style>
```

***
### BaseRadio
```vue:BaseRadio
<template>
  <template v-for="option in options" :key="option.id">
    <base-label :id="option.id">
      <input
        type="radio"
        :id="option.id"
        :name="name"
        :value="option.value"
        @change="updateValue"
      />{{ option.label }}
    </base-label>
  </template>
</template>

<script lang="ts">
import { defineComponent, PropType, SetupContext} from 'vue';
import BaseLabel from './BaseLabel.vue';

export default defineComponent({
  name: 'BaseRadio',
  components: {
    'base-label': BaseLabel
  },
  props: {
    name: {
      type: String as PropType<string>,
      required: true
    },
    value: {
      type: String as PropType<string>,
      required: true
    },
    options: {
      type: Array as PropType<InputItem[]>,
      required: true
    }
  },
  setup(props, context: SetupContext) {
    const updateValue = (e: Event) => {
      if (e.target instanceof HTMLInputElement) {
        context.emit('update:value', e.target.value);
      }
    };
    return {
      props,
      updateValue
    };
  }
});
</script>

<style scoped></style>
```

***
### BaseCheckBox
```vue:BaseCheckBox.vue
<template>
  <template v-for="option in options" :key="option.id">
    <base-label :id="option.id">
      <input
        type="checkbox"
        :id="option.id"
        :name="name"
        :value="option.value"
        @change="updateValue"
      />{{ option.label }}
    </base-label>
  </template>
</template>

<script lang="ts">
import { defineComponent, PropType, reactive, SetupContext } from 'vue';
import BaseLabel from './BaseLabel.vue';

// チェックボックスは複数の値を持つ可能性がある
type CheckboxState = {
  values: string[];
};

export default defineComponent({
  name: 'BaseCheckBox',
  components: {
    'base-label': BaseLabel
  },
  props: {
    name: {
      type: String as PropType<string>,
      required: true
    },
    options: {
      type: Array as PropType<InputItem[]>,
      required: true
    },
    value: {
      type: Array as PropType<string[]>,
      required: true
    }
  },
  setup(props, context: SetupContext) {
    const state = reactive<CheckboxState>({
      values: []
    });
    const updateValue = (e: Event) => {
      if (e.target instanceof HTMLInputElement) {
        const value = e.target.value;
        if (e.target.checked) {
          state.values = [...state.values, value];
        } else {
          state.values = state.values.filter(v => v !== value);
        }
        context.emit('update:value', state.values);
      }
    };
    return {
      props,
      updateValue
    };
  }
});
</script>

<style scoped></style>
```

***
### BaseSelect
```vue:BaseSelect.vue
<template>
  <select :id="id" :name="name" @change="updateValue">
    <option disabled selected value>選択してください</option>
    <template v-for="option in options" :key="option.id">
      <option :value="option.value">
        {{ option.label }}
      </option>
    </template>
  </select>
</template>

<script lang="ts">
import { defineComponent, PropType, SetupContext } from 'vue';

export default defineComponent({
  name: 'BaseSelect',
  props: {
    id: {
      type: String as PropType<string>,
      required: true
    },
    name: {
      type: String as PropType<string>,
      required: true
    },
    options: {
      type: Array as PropType<InputItem[]>,
      required: true
    }
  },
  setup(props, context: SetupContext) {
    const updateValue = (e: Event) => {
      if (e.target instanceof HTMLSelectElement) {
        context.emit('update:value', e.target.value);
      }
    };
    return {
      props,
      updateValue
    };
  }
});
</script>

<style scoped>
select {
  width: 130px;
}
</style>
```

## 使用方法

```vue:FormBox.vue
<template>
  <form class="form-box" @submit.prevent>
    <p>Form</p>
    <fieldset class="form-box__fieldset">
      <legend>inputフォーム</legend>
      <!-- input text -->
      <div class="form-box__input">
        <base-label id="sample-name">name</base-label>
        <base-input
          id="sample-name"
          name="sample-name"
          type="text"
          placeholder="name"
          v-model:value="state.sampleName"
        />
      </div>
      <!-- input email -->
      <div class="form-box__input">
        <base-label id="sample-email">email</base-label>
        <base-input
          id="sample-email"
          name="sample-email"
          type="email"
          placeholder="sample@hoge.com"
          v-model:value="state.sampleEmail"
        />
      </div>
      <!-- input password -->
      <div class="form-box__input">
        <base-label id="sample-password">password</base-label>
        <base-input
          id="sample-password"
          name="sample-password"
          type="password"
          placeholder="password"
          v-model:value="state.samplePassword"
        />
      </div>
      <!-- input textarea -->
      <div class="form-box__input">
        <base-label id="sample-textarea">テキスエリア</base-label>
        <base-text-area
          id="sample-textarea"
          name="sample-textarea"
          :rows="3"
          :cols="50"
          placeholder="テキストエリア"
          v-model:value="state.sampleTextarea"
        />
      </div>
    </fieldset>
    <!-- radio -->
    <fieldset class="form-box__fieldset">
      <legend>使ったことのあるフレームワークは?</legend>
      <base-radio
        name="frontend-used"
        :options="radios"
        v-model:value="state.sampleRadio"
      />
    </fieldset>
    <!-- checkbox -->
    <fieldset class="form-box__fieldset">
      <legend>使ったことのあるバックエンド系言語は?</legend>
      <base-check-box
        name="backend-used"
        :options="checkboxes"
        v-model:value="state.sampleCheck"
      />
    </fieldset>
    <!-- select box -->
    <fieldset class="form-box__fieldset">
      <base-label id="future-learning">Future Learning</base-label>
      <legend>今後学びたい言語は?</legend>
      <base-select
        id="future-learning"
        name="future-learning"
        :options="selects"
        v-model:value="state.sampleSelect"
      />
    </fieldset>
    <div class="form-box__input">
      <input type="submit" value="確定" @click="sendResult" />
    </div>
  </form>
</template>

<script lang="ts">
import { defineComponent, reactive, SetupContext, computed } from 'vue';
import BaseLabel from './forms/BaseLabel.vue';
import BaseCheckBox from './forms/BaseCheckBox.vue';
import BaseInput from './forms/BaseInput.vue';
import BaseRadio from './forms/BaseRadio.vue';
import BaseSelect from './forms/BaseSelect.vue';
import BaseTextArea from './forms/BaseTextArea.vue';
import { selects, radios, checkboxes } from '@/constants/index';
// https://vuelidate-next.netlify.app/
import { useVuelidate } from '@vuelidate/core';
import { helpers, email, required } from '@vuelidate/validators';

export default defineComponent({
  name: 'FormBox',
  components: {
    'base-label': BaseLabel,
    'base-input': BaseInput,
    'base-text-area': BaseTextArea,
    'base-check-box': BaseCheckBox,
    'base-radio': BaseRadio,
    'base-select': BaseSelect
  },
  setup(props, context: SetupContext) {
    const state = reactive<InputState>({
      sampleName: '',
      sampleEmail: '',
      samplePassword: '',
      sampleTextarea: '',
      sampleRadio: '',
      sampleCheck: [],
      sampleSelect: ''
    });

    // バリデーション ルール
    const rules = {
      sampleName: {
        required: helpers.withMessage('nameは必須入力です。', required)
      },
      sampleEmail: {
        required: helpers.withMessage('emailは必須入力です。', required),
        email: helpers.withMessage(
          'メールアドレスの形式が正しくありません。',
          email
        )
      },
      samplePassword: {
        required: helpers.withMessage('passwordは必須入力です。', required)
      },
      sampleTextarea: {
        required: helpers.withMessage(
          'テキストエリアは必須入力です。',
          required
        )
      },
      sampleRadio: {
        required: helpers.withMessage(
          'フレームワークの質問は必須入力です。',
          required
        )
      },
      sampleCheck: {
        required: helpers.withMessage(
          'バックエンド系言語の質問は必須入力です。',
          required
        )
      },
      sampleSelect: {
        required: helpers.withMessage(
          '今後学びたい言語の質問は必須入力です。',
          required
        )
      }
    };

    // バリデーション処理本体
    const validator = useVuelidate(rules, state, { $lazy: true });

    // 送信できる状態か確認する変数
    const canSubmit = computed(() => {
      return !validator.value.$invalid && validator.value.$dirty;
    });

    // 親コンポーネントに伝える
    const sendResult = async () => {
      await validator.value.$validate();
      if (canSubmit.value) {
        context.emit('send-result', state);
      }
      context.emit('send-errors', validator.value.$errors);
    };

    return {
      state,
      sendResult,
      selects,
      radios,
      checkboxes
    };
  }
});
</script>
```

```typescript:constants/index.ts
const radios: InputItem[] = [
  {
    id: 'vue',
    label: 'Vue',
    value: 'vue'
  },
  {
    id: 'react',
    label: 'React',
    value: 'react'
  },
  {
    id: 'angular',
    label: 'Angular',
    value: 'angular'
  }
];

const checkboxes: InputItem[] = [
  {
    id: 'php',
    label: 'PHP',
    value: 'php'
  },
  {
    id: 'ruby',
    label: 'Ruby',
    value: 'ruby'
  },
  {
    id: 'python',
    label: 'Python',
    value: 'python'
  }
];

const selects: InputItem[] = [
  {
    id: 'go',
    label: 'Go',
    value: 'go'
  },
  {
    id: 'rust',
    label: 'Rust',
    value: 'rust'
  }
];

export { selects, radios, checkboxes };
```

[サンプル](https://taka1156.github.io/vue3-ts-sample-form)
※ ルーティング面でおかしなところがありますが後日修正するので気にしないでください
~~([vite.confg.ts](https://gist.github.com/taka1156/92c036ba2de20037de00e5a32b99ea84)の設定がおかしい?)~~
2021/2/22:直しました

![スクリーンショット 2021-02-21 0.32.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252811/fb3b117d-bf0d-adbb-949e-23a57f08ff20.png)