フロントエンド勉強会 Vue.jsハンズオン

# 概要

[フロントエンド勉強会](https://qiita.com/masayasviel/private/0e5a403388509e98313d)
各課題を通して[Vue.js](https://ja.vuejs.org/)に慣れる。

基本的には[フロントエンド勉強会 DOM操作](https://qiita.com/masayasviel/private/216bf3d06e59597b0fcc)をVue.jsでなぞる。

より詳しく段階を踏んで慣れ親しみたい場合は[チュートリアル](https://ja.vuejs.org/tutorial/#step-1)をやるとよい。

本記事ではVue3を用いる。ネットで調べるとVue2の書き方をしている記事もかなりヒットするので注意。

# 環境構築

[vue.js クイックスタート](https://ja.vuejs.org/guide/quick-start.html)に従う。

```sh
npm create vue@latest
```

色々訊かれるので以下のように回答。
プロジェクト名は好きな名前をつければよい。
`%project_name%` を置き換える。

```sh
Ok to proceed? (y) # Enter
Vue.js - The Progressive JavaScript Framework

✔ Project name: … %project_name%
✔ Add TypeScript? … Yes
✔ Add JSX Support? … No
✔ Add Vue Router for Single Page Application development? … No
✔ Add Pinia for state management? … No
✔ Add Vitest for Unit Testing? … No
✔ Add an End-to-End Testing Solution? › No
✔ Add ESLint for code quality? … Yes
✔ Add Prettier for code formatting? … Yes
```

回答し終えると次の指示が出てくるので、その通りに実行

```sh
Done. Now run:

  cd %project_name%
  npm install
  npm run format
  npm run dev
```

`npm run dev` でウェルカムページが立ち上がる。

```
VITE vx.y.z  ready in nnn ms

  ➜  Local:   http://localhost:%PORT%/
  ➜  Network: use --host to expose
  ➜  press h to show help
```

以後、動作確認は `npm run dev` で行うものとする。

また、これからこのプロジェクトで各問題を解くため、 `App.vue` を以下の通りに真っさらにする。

```App.vue
<script setup lang="ts">
</script>

<template>
  <main>
  </main>
</template>

<style scoped>
</style>
```

これに伴い、 `components/` 配下のコンポーネントが要らなくなったので削除。

```sh
rm -rf src/components/*
```

消していいか確認されたら `y` でOK。

```
sure you want to delete all 4 files in /path/to/%project_name%/src/components [yn]? y
```

また、CSSが邪魔なので、 `src/main.ts` の

```ts
import './assets/main.css';
```

を削除。

# 全体的な資料

- [Vue.jsガイド](https://ja.vuejs.org/guide/introduction.html)
- [【Vue.js 3.2】`<script setup>` 構文がすごくすごい](https://zenn.dev/azukiazusa/articles/676d88675e4e74)

# 課題
## アイテム追加

「追加」ボタンを押下することで、フォームの入力値をリストに追加できる。
加えて、追加ボタンが押下された後にフォームの中身をクリアする。

![スクリーンショット 2023-11-11 17.54.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299962/602b3485-3f64-abe8-daa8-6f75e208f9a5.png)

```vue
<script setup lang="ts">
import { ref } from 'vue';

/** 入力値 */
const itemInput = ref<string>('');
/** アイテム一覧 */
const items = ref<string[]>([]);

/** アイテム追加 */
function appendItem() {
  const currentInputValue = itemInput.value;
  if (currentInputValue) {
    items.value.push(currentInputValue);
    itemInput.value = '';
  }
}
</script>

<template>
  <main>
    <div class="forms">
      <div class="input-form">
        <label for="new-item">アイテム</label>
        <input v-model.trim="itemInput" type="text" id="new-item">
      </div>
      <button @click="appendItem">追加</button>
    </div>
    <div>
      <p v-for="item in items">- {{ item }}</p>
    </div>
  </main>
</template>

<style scoped>
.input-form {
  display: flex;
  flex-direction: row;
  gap: 8px;
}

.forms {
  display: flex;
  flex-direction: row;
  gap: 16px;
}

</style>

```

- [リアクティビティーの基礎](https://ja.vuejs.org/guide/essentials/reactivity-fundamentals.html)
- [フォーム入力バインディング](https://ja.vuejs.org/guide/essentials/forms.html#text)
- [イベントハンドリング](https://ja.vuejs.org/guide/essentials/event-handling.html)
- [v-for](https://ja.vuejs.org/guide/essentials/list.html)
- [Flexboxチートシート](https://www.webcreatorbox.com/tech/css-flexbox-cheat-sheet)


## カウンター

+を押したら表示された数字がインクリメント、-を押すとデクリメントされる。
特に処理はしていないので負の値も入る。

![スクリーンショット 2023-11-12 0.07.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299962/4ae514f5-74ae-df74-8ffb-4ffa0525a164.png)

```vue
<script setup lang="ts">
import { ref } from 'vue';

const currentValue = ref<number>(0);
</script>

<template>
  <main>
    <p>現在値 {{ currentValue }}</p>
    <div class="my-button">
      <button @click="currentValue--">-</button>
      <button @click="currentValue++">+</button>
    </div>
  </main>
</template>

<style scoped>
.my-button {
  display: flex;
  flex-direction: row;
  gap: 16px;
}
</style>
```

## バリデーション

必須文字を選択して、その文字が1文字も含まれていなければバリデーションを赤文字で表示する。

![スクリーンショット 2023-11-12 16.21.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299962/eee88dd5-4931-b6c5-5954-d0691af2dfa6.png)

必須文字選択部分をコンポーネントで作成するため、先に以下の通りに `components/` 配下に `select-char.vue` を作成

```sh
touch src/components/select-char.vue
```

### select-char.vue

```vue
<script setup lang="ts">
import { computed } from 'vue';

/** 文字種 */
export type CheckedCharType = '英大文字' | '英小文字' | '数字';

const props = withDefaults(
  defineProps<{
    checkedChar: CheckedCharType[];
  }>(),
  {
    checkedChar: () => []
  }
);

const emit = defineEmits<{
  (e: 'update:checkedChar', value: CheckedCharType[]): void
}>();

const checkedCharComputed = computed({
  get: () => props.checkedChar,
  set: (value) => {
    emit('update:checkedChar', value);
  },
});
</script>

<template>
  <div>
    <p>必須文字を選択</p>

    <input type="checkbox" id="upperAlphabet" value="英大文字" v-model="checkedCharComputed">
    <label for="upperAlphabet">英大文字</label>

    <input type="checkbox" id="lowerAlphabet" value="英小文字" v-model="checkedCharComputed">
    <label for="lowerAlphabet">英小文字</label>

    <input type="checkbox" id="numberChar" value="数字" v-model="checkedCharComputed">
    <label for="numberChar">数字</label>
  </div>
</template>

<style scoped>
</style>
```

- [コンポーネントの定義](https://ja.vuejs.org/guide/essentials/component-basics.html)
- [v-model:checkbox](https://ja.vuejs.org/guide/essentials/forms.html#checkbox)
- [prop](https://ja.vuejs.org/guide/components/props.html)
- [emit](https://vuejs.org/guide/components/events.html)
- [computed](https://ja.vuejs.org/guide/essentials/computed.html)
- [v-modelの双方向バインディング](https://ja.vuejs.org/guide/components/v-model.html)
- [props のデフォルト値](https://ja.vuejs.org/guide/typescript/composition-api#props-default-values)

### App.vue

```vue
<script setup lang="ts">
import { ref, watch } from 'vue';
import SelectChar from '@/components/select-char.vue';
import { type CheckedCharType } from '@/components/select-char.vue';

/** 選択されている文字種 */
const checkedChar = ref<CheckedCharType[]>([]);
/** 入力された値 */
const inputValue = ref<string>('');
/** バリデーションメッセージ */
let validatedHintMessage = '';
/** バリデーションメッセージを表示するか */
let isShowValidationMessage = false;

/** 共通バリデーションメッセージ変更 */
const commonValidateChange = (
  currentCheckedValue: CheckedCharType[],
  currentInputValue: string
): void => {
  isShowValidationMessage = false;

  // 何も入力されていなければバリデーションは出さない
  if (currentInputValue === '') {
    validatedHintMessage = '';
    return;
  }

  const invalid = new Array<CheckedCharType>();
  if (currentCheckedValue.includes('英大文字') && !/^(?=.*[A-Z]).+$/.test(currentInputValue)) {
    invalid.push('英大文字');
  }
  if (currentCheckedValue.includes('英小文字') && !/^(?=.*[a-z]).+$/.test(currentInputValue)) {
    invalid.push('英小文字');
  }
  if (currentCheckedValue.includes('数字') && !/^(?=.*[0-9]).+$/.test(currentInputValue)) {
    invalid.push('数字');
  }

  isShowValidationMessage = invalid.length > 0;
  validatedHintMessage = invalid.join(', ');
};

watch(inputValue, (newValue) => {
  const currentCheckedValue = checkedChar.value;
  commonValidateChange(currentCheckedValue, newValue);
});

watch(checkedChar, (newValue) => {
  const currentInputValue = inputValue.value;
  commonValidateChange(newValue, currentInputValue);
});
</script>

<template>
  <main>
    <SelectChar v-model:checkedChar="checkedChar"></SelectChar>
    <div>
      <label for="my-input">入力</label>
      <input type="text" id="my-input" v-model="inputValue" />
      <template v-if="isShowValidationMessage">
        <p class="validation-hint">{{ validatedHintMessage }}は必須です</p>
      </template>
    </div>
  </main>
</template>

<style scoped>
.validation-hint {
  font-size: small;
  color: red;
}
</style>
```

- [v-if](https://ja.vuejs.org/guide/essentials/conditional.html)
- [ビルトインの特別な要素](https://ja.vuejs.org/api/built-in-special-elements.html)
- [watch](https://ja.vuejs.org/guide/essentials/watchers.html)
- [Type-Only Imports and Export](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html)
- [特定の文字種だけを1文字以上含むことを強制するバリデーションを正規表現で実現する(PHP/Laravel)](https://qiita.com/haruna-nagayoshi/items/23311b71f8a297e3b334)

#### 補足 `'@/components/select-char.vue'` について

`@/` スタートはエイリアス。
`tsconfig.app.json`を見ると

```json
"paths": {
  "@/*": ["./src/*"]
}
```

の記述が。
`components/` はsrc配下にあるので、 `'@/components/**.vue'` となる。

参考

- [tsconfig#paths](https://www.typescriptlang.org/tsconfig#paths)

## テキストエリアに改行区切りで入力されたものをリスト表示

改行区切りで文字をリスト表示する。
空文字だけならリストに追加はしない。

![スクリーンショット 2023-11-12 17.03.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/299962/cf37c1f7-2284-824e-acc7-572230882f70.png)

```vue
<script setup lang="ts">
import { ref, computed } from 'vue';

const itemInput = ref<string>('');
const items = computed<string[]>(() => itemInput
    .value
    .split('\n')
    .map((s) => s.trim())
    .filter((s) => s)
);
</script>

<template>
  <main>
    <label for="multi-input-area">リスト分割</label>
    <textarea
      v-model.trim="itemInput"
      id="multi-input-area"
      rows="5"
      cols="40"
    ></textarea>

    <div>リスト</div>
    <ul>
      <li v-for="item in items">{{ item }}</li>
    </ul>
  </main>
</template>

<style scoped>
</style>
```