前回のVue.jsでTodoアプリを作る(ソート機能の追加)でソート機能を追加したことでTodoアプリの基本的な機能は持たせることができたと思います。
今回はVue.jsの特徴でもあるコンポーネントを用いてフォーム部分のコンポーネント化をしていきたいと思います。
完了後は以下の画像のようになります。
見た目には特に変化が見られないと思います。では、次にVSCode上のファイル構造を見てみましょう。
新しくTodoInput.vueを作成しています。このファイルの作成方法は「新しくファイルを作成する」で直接作成してしまってOKです。
では、実際に中身のファイルを見てみましょう。
C:/npm_sample/src/components/TodoInput.vue
<template>
<div class="input-area">
<input type="text" v-model="newTodo" @keyup.enter="addTodo">
<input type="date" v-model="newTodoDueDate">
<button @click="addTodo">追加</button>
</div>
</template>
<script>
export default {
data() {
return {
newTodo: '',
newTodoDueDate: ''
};
},
methods: {
addTodo() {
this.$emit('add-todo', {
text: this.newTodo,
dueDate: this.newTodoDueDate
});
},
resetInput() {
this.newTodo = '';
this.newTodoDueDate = '';
}
}
}
</script>
テンプレート部分
<div class="input-area">
: フォーム要素を囲むコンテナで、CSSクラスinput-area
が適用されています。このクラスを使ってスタイルを定義することができます。<input type="text" v-model="newTodo">
: テキスト入力フィールドです。v-model="newTodo"
により、この入力フィールドは Vue インスタンスのnewTodo
データプロパティと双方向バインディングされています。ユーザーが入力した内容はリアルタイムでnewTodo
に反映され、その逆も同様です。@keyup.enter="addTodo"
: キーボードのエンターキーが押されたときにaddTodo
メソッドが呼ばれるように設定されています。<input type="date" v-model="newTodoDueDate">
: 日付入力フィールドです。このフィールドもnewTodoDueDate
データプロパティと双方向にバインディングされています。<button @click="addTodo">追加</button>
: ボタンがクリックされたときにaddTodo
メソッドが実行されるように設定されています。
スクリプト部分
data()
: Vue インスタンスの状態を定義する部分です。newTodo
とnewTodoDueDate
の2つのデータプロパティが定義されており、それぞれがテキスト入力と日付入力にバインドされています。methods
: コンポーネントで使用するメソッドを定義しています。addTodo()
: このメソッドは、親コンポーネントにadd-todo
イベントを発火させます。イベントとともにnewTodo
とnewTodoDueDate
の現在の値がオブジェクトとして渡されます。これにより、フォームで入力された新しいToDoの情報が親コンポーネントに伝えられます。resetInput()
: このメソッドは、入力フィールドを初期化します。ToDoが追加された後やフォームをリセットする必要がある場合に呼び出すことができます。
親コンポーネントも見てみましょう。
C:/npm_sample/src/App.vue
<template>
<div id="app">
<h1>ToDo List</h1>
<ul>
<li v-for="todoError in todoErrors" :key="todoError" class="errorDescription">
{{ todoError }}
</li>
</ul>
<todo-input @add-todo="addTodo" ref="todoInput" />
<div>
<select v-model="sortKey" @change="sortTodos">
<option value="default">デフォルト</option>
<option value="completed">完了順</option>
<option value="dueDate">期限順</option>
</select>
</div>
<ul>
<li v-for="(todo, index) in todos" :key="index"
:class="{ 'completed': todo.completed, 'pastDue': isPastDue(todo.dueDate), 'editing': todo.editing }">
<div class="todo-container">
<template v-if="todo.editing">
<input type="text" v-model="todo.text">
<input type="date" v-model="todo.dueDate">
<button @click="saveEdit(todo)">保存</button>
</template>
<template v-else>
<div class="todo-content">
<span>{{ todo.text }}</span>
<span class="due-date">- 期限: {{ todo.dueDate }}</span>
</div>
<div class="buttons">
<button @click="toggleCompleted(index)"
:class="{'completeButton': true, 'completed': todo.completed}">{{ todo.buttonText }}</button>
<button @click="editTodo(todo)">編集</button>
<button @click="deleteTodo(index)" class="deleteButton">削除</button>
</div>
</template>
</div>
</li>
</ul>
</div>
</template>
<script>
import TodoInput from './components/TodoInput.vue';
export default {
components: {
TodoInput
},
data() {
return {
newTodo: '',
newTodoDueDate: '',
todos: [],
todoErrors: [],
sortKey: 'default' // ソートキーの初期値
}
},
created() {
this.loadTodos();
},
methods: {
loadTodos() {
const todos = localStorage.getItem('todos');
if (todos) {
this.todos = JSON.parse(todos);
this.originalTodos = JSON.parse(todos); // ロード時にオリジナルのリストを保存
}
},
saveTodos() {
localStorage.setItem('todos', JSON.stringify(this.todos));
this.originalTodos = [...this.todos]; // 保存時にもオリジナルを更新
},
addTodo(newTodo) {
this.todoErrors = [];
if (!newTodo.text.trim()) {
this.todoErrors.push('Todoを入力してください');
}
if (!newTodo.dueDate) {
this.todoErrors.push('期限を入力してください');
}
if (this.todoErrors.length === 0) {
this.todos.push({
id: this.todos.length + 1,
text: newTodo.text,
dueDate: newTodo.dueDate,
completed: false,
editing: false,
buttonText: '完了'
});
this.saveTodos();
this.$refs.todoInput.resetInput();
}
},
toggleCompleted(index) {
const todo = this.todos[index];
todo.completed = !todo.completed; // 完了状態を切り替え
todo.buttonText = todo.completed ? '未完了' : '完了'; // ボタンテキストも更新
this.saveTodos();
},
deleteTodo(index) {
if (window.confirm("本当に削除しますか?")) {
this.todos.splice(index, 1);
this.saveTodos()
}
},
editTodo(todo) {
todo.editing = true;
},
saveEdit(todo) {
this.todoErrors = [];
if (!todo.text) {
this.todoErrors.push('Todoを入力してください');
}
if (!todo.dueDate) {
this.todoErrors.push('期限を入力してください')
}
if (this.todoErrors.length === 0) {
todo.editing = false
this.saveTodos()
}
},
isPastDue(dueDate) {
const today = new Date();
today.setHours(0, 0, 0, 0); // 時間をリセットして今日の日付のみにする
const due = new Date(dueDate);
return due < today; //期限が今日より前であればtrueを返す
},
sortTodos() {
if (this.sortKey === 'default') {
this.todos = [...this.originalTodos]; // デフォルト時はオリジナルのリストを復元
} else {
let sortedTodos = [...this.originalTodos]; // ソートはオリジナルから始める
if (this.sortKey === 'completed') {
sortedTodos.sort((a, b) => (a.completed === b.completed ? 0 : a.completed ? 1 : -1));
} else if (this.sortKey === 'dueDate') {
sortedTodos.sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate));
}
this.todos = sortedTodos; // ソートされた配列を元の配列に再代入
}
},
}
}
</script>
<style>
#app {
max-width: 600px;
margin: 0 auto;
font-family: 'Helvetica Neue', Arial, sans-serif;
}
.input-area {
display: flex;
margin-bottom: 20px;
}
input[type="text"], input[type="date"] {
flex: 1;
padding: 10px;
border: 2px solid #ccc;
border-radius: 4px;
margin-right: 5px;
}
/* フォーカス時のスタイル */
input[type="date"]:focus {
outline: none;
border-color: #4CAF50;
}
button {
background-color: #4CAF50;
border: none;
color: white;
padding: 10px 15px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
cursor: pointer;
border-radius: 4px;
margin-left: 5px;
}
.todo-container {
display: flex;
justify-content: space-between;
align-items: center;
}
.todo-content {
display: flex;
flex-direction: column;
}
.due-date {
color: #666;
font-size: 0.9em;
}
.buttons {
display: flex;
align-items: center;
}
button {
background-color: #4CAF50; /* 緑色のボタン */
border: none; /* 枠線なし */
color: white; /* テキスト色 */
padding: 10px 15px; /* パディング */
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
transition-duration: 0.4s; /* ホバー効果のためのトランジション */
cursor: pointer; /* マウスカーソルを指に */
border-radius: 4px; /* 角を丸く */
}
button:hover {
background-color: #45a049; /* ホバー時に少し暗く */
}
.completeButton {
background-color: rgb(24, 130, 243); /* 青色のボタン */
border: none; /* 枠線なし */
color: white; /* テキスト色 */
padding: 10px 15px; /* パディング */
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
transition-duration: 0.4s; /* ホバー効果のためのトランジション */
cursor: pointer; /* マウスカーソルを指に */
border-radius: 4px; /* 角を丸く */
}
.deleteButton {
background-color: rgb(243, 24, 24); /* 赤色のボタン */
border: none; /* 枠線なし */
color: white; /* テキスト色 */
padding: 10px 15px; /* パディング */
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
transition-duration: 0.4s; /* ホバー効果のためのトランジション */
cursor: pointer; /* マウスカーソルを指に */
border-radius: 4px; /* 角を丸く */
}
/* ホバー時のスタイル */
.completeButton:hover,
.deleteButton:hover {
opacity: 0.85; /* ボタンを少し透明にすることでホバー効果を強調 */
}
.completeButton, .deleteButton {
margin-left: 10px;
}
.completed {
background-color: #d4edda; /* 完了項目の背景色 */
color: #155724; /* 完了項目のテキスト色 */
text-decoration: line-through; /* 完了項目に取り消し線を追加 */
}
/* 完了ボタンのスタイルを条件によって変更するためのスタイルを追加 */
.completeButton.completed {
color: white;
background-color: #2846a7; /* 完了時のボタンの背景色 */
}
ul {
list-style-type: none;
padding: 0;
}
li {
margin: 8px 0;
background-color: #f3f3f3;
padding: 10px;
border-radius: 4px;
}
.pastDue {
color: red;
}
.errorDescription {
color: red;
}
</style>
大きな変更のあった個所は赤字で表示しています。
フォーム関連部分
todo-input コンポーネント: ユーザーが新しいToDoを追加するためのインプットエリアです。このコンポーネントはカスタムコンポーネントであり、TodoInput.vue
ファイルで定義されています。
@add-todo="addTodo"
: この部分では、todo-input
コンポーネントから発火されるadd-todo
イベントをリッスンし、そのイベントがトリガーされたときに親コンポーネントのaddTodo
メソッドを実行します。ref="todoInput"
: Vueの参照(refs)を使用して、親コンポーネントから直接子コンポーネントにアクセスできるように設定しています。これにより、親コンポーネントはtodoInput
コンポーネントのメソッドやデータプロパティに直接アクセスすることが可能です。
コメント