7
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-02-20

Vue.jsでFormの各要素をComponent化する際の覚え書き - @yo2132

@yo2132さんの記事が素晴らしいと思ったと同時に、自分用のTS記事も欲しかったので記載。

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

vue3-ts-sample-form - github

補足

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

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

詳しくはコチラ
Vue3でv-modelがどう変わったか - @ryo-gk


補足2: バリデーションにvuelidate-nextを使用
vuelidate/vuelidate - github


修正

修正 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(バリデーションエラーを表示)

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

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

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

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

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

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

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>

使用方法

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>
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 };

サンプル
※ ルーティング面でおかしなところがありますが後日修正するので気にしないでください
(vite.confg.tsの設定がおかしい?)
2021/2/22:直しました

スクリーンショット 2021-02-21 0.32.19.png

7
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?