🌺

Vue3 Composition API vuetify3を使って TodoListを作ってみた!!

2023/08/09に公開

はじめに

Vue3のCompofition APIとvuetifyを使ってTodoListアプリを共有します。
ネットの色んな記事を見るとvue2系のoptionsAPIの書き方だったり、vuetify2の書き方だったりなど書き方がバラバラなので、vue3やvuetify3のの書き方があればいいなと思って今回この記事を作成しました!

成果物


対象者

  • これからフロントエンド領域でも開発してみたい
  • 手軽にアウトプットできるものないかを探している方
  • vue.jsをこれから触り始めたい、まだ触り始めて初心者の方
  • vuetifyを使った開発をしてみたい(Cssを書くのが面倒くさいと思っている方)

上記当てはまりましたら、ぜひ読んでみてください!
また、サンプルのソースコードも下記に置いてありますので参考にしてみてください!

作る機能

  • TodoList一覧機能
  • Todo追加機能
  • Todoの絞り込み機能
  • Todoの削除機能
  • モーダル画面(v-dialog)

環境構築

vueプロジェクト作成コマンド

$ npm init vue@latest

色々と質問されますが、

  • プロジェクト名を記入
  • Add TypeScript => Yes
  • Add ESlint => Yes
  • Add Prettier => Yes

それ以外はNoを選択して大丈夫です!

$ cd 作成したプロジェクト名
$ npm install
$ npm run dev

http://localhost:5173/

上記にアクセスして初期画面が表示れます。

ディレクトリ構成

ディレクトリ構成は下記のとおりです。

|--src
|  |--App.vue
|  |--assets
|  |  |--main.css
|  |--components
|  |  |--AddTodo.vue
|  |  |--BaseSection.vue
|  |  |--SearchTodo.vue
|  |  |--TodoList.vue
|  |  |--modal
|  |  |  |--ConfirmDialog.vue
|  |--constants
|  |  |--todo.ts
|  |--interfaces
|  |  |--Todo.ts
|  |--main.ts
|--tsconfig.app.json
|--tsconfig.json
|--tsconfig.node.json
|--vite.config.ts

vuetifyの環境設定

main.ts

vuefityで使うcomponentやstyleなど、main.tsファイルで設定します。
詳細な設定方法はvuetifyのManual Stepsにあります!

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

import { createApp } from 'vue'
import App from './App.vue'
import 'vuetify/styles'
import '@mdi/font/css/materialdesignicons.css'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'

const vuetify = createVuetify({
  components,
  directives
})

createApp(App).use(vuetify).mount('#app')

TodoListの画面構成

コンポーネントの設計としては下記の構成になります。BaseSectionコンポーネントをApp.vueに読み込んでいます。
BaseSectionコンポーネントの中身

  • AddTodo.vue
  • SearchTodo.vue
  • TodoList.vue
  • ConfirmDialog.vue

ソースコード

App.vue
<template>
  <BaseSection />
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import BaseSection from '@/components/BaseSection.vue'

export default defineComponent({
  components: { BaseSection },
  setup() {
    return {}
  }
})
</script>
<style></style>

BaseSection.vue
<template>
  <v-app>
    <v-main>
      <v-container class="mt-12">
        <v-row justify="center">
          <v-col cols="12" sm="8" md="6">
            <h1 class="text-center">Todo List</h1>
            <v-card class="pa-10">
              <v-form>
                <!-- Todo新規追加 -->
                <AddTodo v-model:title="title" @handleSubmit="handleSubmit" />
                <!-- Todoキーワード検索 -->
                <SearchTodo v-model:searchKeyword="searchKeyword" />
                <!-- TodoList一覧 -->
                <TodoList :filtered-todo-list="filteredTodoList" @handleDelete="handleDelete" />
              </v-form>
            </v-card>
          </v-col>
        </v-row>
      </v-container>
      <ConfirmDialog
        card-title="Todoを削除"
        :card-text="cardText"
        :modelValue="showDeleteDialog"
        @cancel="cancel"
        @confirmDeleteTodo="confirmDeleteTodo(cardToDeleteId)"
      />
    </v-main>
  </v-app>
</template>

<script lang="ts">
import { defineComponent, ref, computed } from 'vue'
import AddTodo from '@/components/AddTodo.vue'
import SearchTodo from '@/components/SearchTodo.vue'
import TodoList from '@/components/TodoList.vue'
import ConfirmDialog from './modal/ConfirmDialog.vue'
import { INIT_TODO_LIST } from '@/constants/todo'
import type { TodoType } from '@/interfaces/Todo'

export default defineComponent({
  components: { AddTodo, SearchTodo, TodoList, ConfirmDialog },
  setup() {
    const title = ref('')
    const searchKeyword = ref('')
    const nextTodoId = ref(4)
    const cardText = ref('')
    const cardToDeleteId = ref(0)
    const showDeleteDialog = ref(false)
    const todoList = ref<TodoType[]>(INIT_TODO_LIST)

    const filteredTodoList = computed(() => {
      const keyword = searchKeyword.value.trim().toLowerCase()
      if (!keyword) {
        return todoList.value
      } else {
        return todoList.value.filter((item) => item.title.toLowerCase().startsWith(keyword))
      }
    })

    const handleSubmit = () => {
      if (title.value === '') return
      const newTodo: TodoType = {
        id: nextTodoId.value,
        title: title.value.trim() // 入力値をトリムしてから使用
      }
      todoList.value.push(newTodo) // 新しいTodoをtodosに追加
      title.value = '' // 入力フィールドをクリア

      nextTodoId.value++ // 次のTodoのIDを更新
    }

    const handleDelete = (targetId: number, targetTitle: string) => {
      cardText.value = targetTitle
      cardToDeleteId.value = targetId
      showDeleteDialog.value = true
    }

    const cancel = () => {
      showDeleteDialog.value = false
    }

    const confirmDeleteTodo = (targetId: number) => {
      todoList.value = todoList.value.filter((todo) => todo.id !== targetId)
      showDeleteDialog.value = false
    }

    return {
      title,
      searchKeyword,
      todoList,
      cardText,
      cardToDeleteId,
      showDeleteDialog,
      filteredTodoList,
      handleSubmit,
      handleDelete,
      cancel,
      confirmDeleteTodo
    }
  }
})
</script>
<style></style>

AddTodo.vue
<template>
  <v-text-field
    type="text"
    :value="title"
    @input="onInput"
    label="Todoを追加"
    class="mb-4"
    variant="outlined"
    @keypress.enter="handleSubmit"
  ></v-text-field>
</template>

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

export default defineComponent({
  props: {
    title: {
      type: String,
      default: ''
    }
  },
  setup(_, context) {
    const onInput = (e: Event) => {
      const target = e.target as HTMLInputElement
      context.emit('update:title', target.value)
    }
    const handleSubmit = () => {
      context.emit('handleSubmit')
    }
    return {
      onInput,
      handleSubmit
    }
  }
})
</script>

<style scoped></style>

SearchTodo.vue
<template>
  <v-text-field
    :value="searchKeyword"
    @input="onInput"
    label="キーワード検索"
    variant="outlined"
  ></v-text-field>
</template>

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

export default defineComponent({
  props: {
    searchKeyword: {
      type: String,
      default: ''
    }
  },
  setup(_, context) {
    const onInput = (e: Event) => {
      const target = e.target as HTMLInputElement
      context.emit('update:searchKeyword', target.value)
    }

    return {
      onInput
    }
  }
})
</script>

<style scoped></style>

TodoList.vue
<template>
  <v-list>
    <v-list-item
      class="mb-6"
      border
      height="60"
      lines="two"
      rounded
      v-for="(item, index) in filteredTodoList"
      :key="index"
    >
      <v-list-item-title>
        {{ item.title }}
      </v-list-item-title>
      <template v-slot:append>
        <v-list-item-action>
          <v-icon @click="handleDelete(item.id, item.title)">mdi-delete</v-icon>
        </v-list-item-action>
      </template>
    </v-list-item>
  </v-list>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import type { TodoType } from '@/interfaces/Todo'
import type { PropType } from 'vue'

export default defineComponent({
  props: {
    filteredTodoList: {
      type: Array as PropType<TodoType[]>
    }
  },
  setup(_, context) {
    const handleDelete = (targetId: Number, targetTitle: String) => {
      context.emit('handleDelete', targetId, targetTitle)
    }

    return {
      // filteredTodoList,
      handleDelete
    }
  }
})
</script>

<style scoped></style>

modal/ConfirmDialog.vue
<template>
  <v-dialog v-model="showDeleteDialog" max-width="600">
    <v-card>
      <v-card-title class="headline">{{ cardTitle }}</v-card-title>
      <v-card-text>
        {{ `${cardText}を削除しますか?` }}
      </v-card-text>
      <v-card-actions>
        <v-btn @click="cancel">キャンセル</v-btn>
        <v-btn color="primary" @click="confirmDeleteTodo">削除</v-btn>
      </v-card-actions>
    </v-card>
  </v-dialog>
</template>

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

export default defineComponent({
  name: 'ConfirmDeleteTodo',
  props: {
    cardTitle: {
      type: String,
      default: ''
    },
    cardText: {
      type: String,
      default: ''
    }
  },
  setup(_, context) {
    const showDeleteDialog = ref(false)

    const cancel = () => {
      context.emit('cancel')
    }

    const confirmDeleteTodo = () => {
      context.emit('confirmDeleteTodo')
    }
    return {
      showDeleteDialog,
      cancel,
      confirmDeleteTodo
    }
  }
})
</script>

<style scoped></style>

型定義と定数

interfaces/Todo.ts
export interface TodoType {
  id: number
  title: string
}

constants/todo.ts
import type { TodoType } from '@/interfaces/Todo'

export const INIT_TODO_LIST: TodoType[] = [
  { id: 1, title: 'Todo1' },
  { id: 2, title: 'Todo2' },
  { id: 3, title: 'Todo3' }
]

最後に

今回はvue3のcomposition APIとvuetifyを使ってTodoListアプリを作ってみました!
propsやemit、vuetifyのUIのライブラリを使って開発はいかがだったでしょうか?
v-modelやpropsやemit、vuetifyの流れが少しでも分かって頂けると嬉しいです!
vue3.2からsetup構文を使った書き方などがありますが、基礎を押さえていればsetup構文に移行しても問題なく対応できると思います。

参考文献

vuetify公式ドキュメント
https://vuetifyjs.com/en/

コンポーネントのv-model
https://ja.vuejs.org/guide/components/v-model.html

propsの使い方
https://reffect.co.jp/vue/vue-js-master-props-for-beginner/

Vue3/TypeScript対応 emitで理解するVue.jsの本質
https://zenn.dev/rio_dev/articles/bc9dd92bfebe95

書籍

Vue 3 フロントエンド開発の教科書
https://www.amazon.co.jp/Vue-3-フロントエンド開発の教科書-WINGSプロジェクト-齊藤-新三/dp/4297130726/ref=sr_1_2_sspa?__mk_ja_JP=カタカナ&crid=2Y5521HVV19VE&keywords=vue3&qid=1691580607&sprefix=vue3%2Caps%2C204&sr=8-2-spons&sp_csd=d2lkZ2V0TmFtZT1zcF9hdGY&psc=1

Discussion