サイトトップ

Director Flash 書籍 業務内容 プロフィール

HTML5テクニカルノート

Vue.js + ES6: TodoMVCをつくる 05 ー 項目のテキストを再編集できるようにする


Vue.js + ES6: TodoMVCをつくる 04 ー チェックをまとめてオン/オフしたり削除する」(以下「Vue.js + ES6: TodoMVCをつくる 04」)では、項目にまとめてチェックをつけたり、チェック済みの項目すべてを削除できるようにしました。今回は、項目を削除して追加するのでなく、すでに入力した項目のテキストを書き替えられるようにします。

01 項目のダブルクリックでテキスト入力フィールドを出す

項目を書き替えるときも、ボタンは使わず、つぎのようにテキスト(<label>要素)のダブルクリックで進めます。v-on:dblclick(省略記法@dblclick)イベントのリスナーメソッド(editTodo())に渡すのは、項目のオブジェクト(todo)です[*1]。ユーザーがテキストを編集するために、テキスト入力フィールド(<input>要素)を新たに加えました。v-modelディレクティブで項目のテキスト(title)をバインディングしています。

ダブルクリック(@dblclickイベント)のリスナーメソッド(editTodo())は、CSSを使ってテキスト(<label>要素)と入力フィールド(<input>要素)を差し替えるつもりです。そのため、親要素(<li>)にはv-bind:class(省略記法:class)構文に新たなクラス(editing)をバインディングしました。

<body>要素

<section class="main" v-show="todos.length" v-cloak>

	<ul class="todo-list">
		<li v-for="todo in filteredTodos"

			:class="{completed: todo.completed, editing: todo == editedTodo}">
			<div class="view">

				<label @dblclick="editTodo(todo)">{{todo.title}}</label>

			</div>
			<input class="edit" type="text"
				v-model="todo.title">

		</li>
	</ul>
</section>

アプリケーションには、つぎのようにdataに新たなプロパティ(editedTodo)を加えます。methodsに定める新たなメソッド(editTodo())は、項目のダブルクリック(@dblclickイベント)のリスナーです。このメソッドが引数に渡された項目のオブジェクト(todo)をプロパティに与えます。すると、前掲:class構文により、ダブルクリックされた項目(<li>要素)にクラス(editing)がバインディングされるのです。

script.js

const app = new Vue({
	data: {

		editedTodo: null,

	},

	methods: {

		editTodo(todo) {
			this.editedTodo = todo;
		},

	}
});

<link>要素に読み込んだCSSファイル(index.css)にはつぎのように定められています。テキスト入力フィールド(クラスedit)ははじめは表示されていません。テキスト(<label>要素)をダブルクリックすると、前掲:class構文で項目(<li>要素)にクラス(editing)がバインディングされました。すると、その項目のテキストを含む要素(クラスview)が隠れて、替わりに入力フィールドがv-modelディレクティブでバインディングされたテキストを含んで表れるのです(図001)。テキストの編集はできるものの、まだフィールドを閉じることができません。

index.css

.todo-list li.editing .edit {
	display: block;

}

.todo-list li.editing .view {
	display: none;
}

.todo-list li .edit {
	display: none;
}

図001■ダブルクリックをした項目のテキストを含んだ入力フィールドが表れる

図001

[*1] Vue.jsサイトの「イベントハンドリング」には、dblclickイベントが載っていません。けれど、JavaScriptネイティブのイベントはサポートされます(「Why not add v-on:doubleClick」参照)。

02 テキストの編集確定とキャンセルができるようにする

テキスト入力フィールドが閉じられないといけません。テキストの編集は確定する場合だけでなく、キャンセルしたいときもあるでしょう。項目のダブルクリックで編集状態にしましたので、ここもボタンなどは加えずに、[return]/[Enter]キーで確定、[esc]キーでキャンセルにします。テキスト入力フィールド(<input>要素)に、v-on:keypress.enter(省略記法@keypress.enter)とv-on:keyup.esc(省略記法@keyup.esc)のふたつのイベントリスナー(doneEdit()cancelEdit())を定めます。どちらのメソッドも、引数に渡すのは項目のオブジェクト(todo)です。

<body>要素

<section class="main" v-show="todos.length" v-cloak>

	<ul class="todo-list">
		<li v-for="todo in filteredTodos"

			:class="{completed: todo.completed, editing: todo == editedTodo}">

			<input class="edit" type="text"
				v-model="todo.title"
				@keypress.enter="doneEdit(todo)"
				@keyup.esc="cancelEdit(todo)">

		</li>
	</ul>
</section>

前項01で、項目のテキスト(<label>要素)をダブルクリックしたとき(@dblclickイベント)、リスナー関数(editTodo())がその項目のオブジェクトをプロパティ(editedTodo)に収めました。そこで、[return]/[enter]キーのイベントリスナー(doneEdit())は、以下のようにまずそのオブジェクトの存在により編集状態であることを確かめます。そのうえで、プロパティは空(null)にして、編集されたテキストの両端の空白をString.trim()メソッドで除きます。そして、テキスト(title)があれば項目のオブジェクト(todo)のプロパティ(title)を書き替え、空のときは項目そのものをメソッド(removeTodo())で削除しました。

[esc]キーのイベントリスナー(cancelEdit())は、テキストを編集が始まる前に戻さなければなりません。そのため、ダブルクリックのイベントリスナー(editTodo())は、テキストをプロパティ(beforeEditCache)にとっておくようにしました。キャンセルのリスナー関数は、編集項目のプロパティ(editedTodo)は空(null)にして、項目オブジェクト(todo)のテキストのプロパティ(title)をもとに戻します。

script.js

const app = new Vue({
	data: {

		beforeEditCache: ''
	},

	methods: {

		editTodo(todo) {
			this.beforeEditCache = todo.title;

		}, 
		doneEdit(todo) {
			if (!this.editedTodo) {
				return;
			}
			this.editedTodo = null;
			const title = todo.title.trim();
			if (title) {
				todo.title = title;
			} else {
				this.removeTodo(todo);
			}
		},
		cancelEdit(todo) {
			this.editedTodo = null;
			todo.title = this.beforeEditCache;
		},

	}
});

これで、項目をダブルクリックして編集ができ、[return]/[Enter]キーで確定、[esc]キーを押せばキャンセルされるようになりました(図002)。

図002■ダブルクリックで編集した項目をキーボードで確定/キャンセルできる

図002

03 項目の編集からフォーカスを外したときの扱い

Todoリストはほぼでき上がりました。けれど、まだ不具合が残っています。項目の編集は[return]/[Enter]か[esc]キーを押さないかぎり終わりません。編集途中のテキスト入力フィールドを開いたまま、追加項目が入力できてしまうのです(図003)。

図003■編集を終えないまま項目が加えられる

図003

テキスト入力フィールド(<input>要素)からフォーカスを外したときに、編集を終えるようにしなければなりません。イベントはblurです。つぎのように、編集確定のリスナー関数(doneEdit())を与えました。これで、編集した入力フィールドからフォーカスを外せば、テキストが確定します。

<body>要素

<section class="main" v-show="todos.length" v-cloak>

	<ul class="todo-list">
		<li v-for="todo in filteredTodos"

			:class="{completed: todo.completed, editing: todo == editedTodo}">

			<input class="edit" type="text"
				v-model="todo.title"
				@keypress.enter="doneEdit(todo)"
				@keyup.esc="cancelEdit(todo)"
				@blur="doneEdit(todo)">

		</li>
	</ul>
</section>

まだひとつ問題が残っています。項目をダブルクリックしたあと、テキスト入力フィールドに触れずに別の場所をクリックしても、編集が終わりません。追加項目も入力できてしまいます。それは、項目をダブルクリックしたとき、テキスト入力フィールドにフォーカスが入らないからです。そこで、「カスタムディレクティブ」を使うことにします。

カスタムディレクティブには、v-につづけて任意の名前を定めます。つぎのように、テキスト入力フィールド(<input>要素)にディレクティブ(todo-focus)を加えました。与えた式の値はアプリケーションに定めるディレクティブのメソッドから取り出せます。

<body>要素

<section class="main" v-show="todos.length" v-cloak>

	<ul class="todo-list">
		<li v-for="todo in filteredTodos"

			:class="{completed: todo.completed, editing: todo == editedTodo}">

			<input class="edit" type="text"

				v-todo-focus="todo == editedTodo"

				@blur="doneEdit(todo)">

		</li>
	</ul>
</section>

ディレクティブはVue()コンストラクタに渡すオブジェクトのdirectivesに、以下のようにv-は除いたメソッド名(todo-focus)で定めます[*2]。渡される引数は、ディレクティブが加えられた要素(element)と、バインディングの情報をもつオブジェクト(binding)です。アプリケーションがディレクティブをもつ要素を紐づけ(初期化)したとき、およびその要素のコンポーネントが更新されたときに呼び出されます[*3]

ディレクティブを加えたテキスト入力フィールドが非表示から表示に変わるのは、コンポーネントの更新です。ディレクティブのメソッドに渡された第2引数(binding)のvalueプロパティで、前掲カスタムディレクティブに与えた式の値、つまり編集する項目かどうかがブール(論理)値で得られます。編集項目なら、要素をelement.focus()メソッドでフォーカスすればよいわけです。これで、項目のテキストをダブルクリックすると、開いたテキスト入力フィールドにフォーカスが当たります。

script.js

const app = new Vue({

	directives: {
		'todo-focus'(element, binding) {
			if (binding.value) {
				element.focus();
			}
		}
	}
});

ダブルクリックで編集した項目のテキストを、確定あるいはキャンセルするインタフェースができました。もともとのお題であった「TodoMVC の例」の実装がすべて整ったことになります。HTMLドキュメントの<body>要素とJavaScriptファイルの中身はつぎのコード001にまとめたとおりです。また、CodePenに以下のサンプル001を掲げました。

コード001■項目のダブルクリックで編集してキーボードから確定/キャンセルできる

<body>要素

<section class="todoapp">
	<header class="header">
		<h1>todos</h1>
		<input class="new-todo"
			autofocus
			autocomplete="off"
			placeholder="What needs to be done?"
			v-model="newTodo"
			@keypress.enter="addTodo">
	</header>
	<section class="main" v-show="todos.length" v-cloak>
		<input id="toggle-all" class="toggle-all" type="checkbox" v-model="allDone">
		<label for="toggle-all"></label>
		<ul class="todo-list">
			<li v-for="todo in filteredTodos"
				class="todo"
				:key="todo.id"
				:class="{completed: todo.completed, editing: todo == editedTodo}">
				<div class="view">
					<input class="toggle" type="checkbox" v-model="todo.completed">
					<label @dblclick="editTodo(todo)">{{todo.title}}</label>
					<button class="destroy" @click="removeTodo(todo)"></button>
				</div>
				<input class="edit" type="text"
					v-model="todo.title"
					v-todo-focus="todo == editedTodo"
					@keypress.enter="doneEdit(todo)"
					@keyup.esc="cancelEdit(todo)"
					@blur="doneEdit(todo)">
			</li>
		</ul>
	</section>
	<footer class="footer" v-show="todos.length" v-cloak>
		<span class="todo-count">
			<strong>{{remaining}}</strong> {{remaining | pluralize}} left
		</span>
		<ul class="filters">
			<li><a href="#/all" :class="{selected: visibility == 'all'}">All</a></li>
			<li><a href="#/active" :class="{selected: visibility == 'active'}">Active</a></li>
			<li><a href="#/completed" :class="{selected: visibility == 'completed'}">Completed</a></li>
		</ul>
		<button class="clear-completed" @click="removeCompleted" v-show="todos.length > remaining">
			Clear completed
		</button>
	</footer>
</section>

script.js

const STORAGE_KEY = 'todos-vuejs-2.0';
const todoStorage = {
	fetch() {
		const todos = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
		todos.forEach((todo, index) =>
			todo.id = index
		);
		todoStorage.uid = todos.length;
		return todos;
	},
	save(todos) {
		localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
	}
};
const filters = {
	all(todos) {
		return todos;
	},
	active(todos) {
		return todos.filter((todo) =>
			!todo.completed
		);
	},
	completed(todos) {
		return todos.filter((todo) =>
			todo.completed
		);
	}
};
const app = new Vue({
	data: {
		todos: todoStorage.fetch(),
		newTodo: '',
		editedTodo: null,
		visibility: 'all',
		beforeEditCache: ''
	},
	watch: {
		todos: {
			handler(todos) {
				todoStorage.save(todos);
			},
			deep: true
		}
	},
	computed: {
		filteredTodos() {
			return filters[this.visibility](this.todos);
		},
		remaining() {
			const todos = filters.active(this.todos);
			return todos.length;
		},
		allDone: {
			get() {
				return this.remaining === 0;
			},
			set(value) {
				this.todos.forEach((todo) =>
					todo.completed = value
				);
			}
		}
	},
	filters: {
		pluralize(n) {
			return n === 1 ? 'item' : 'items';
		}
	},
	methods: {
		addTodo() {
			const value = this.newTodo && this.newTodo.trim();
			if (!value) {
				return;
			}
			this.todos.push({
				id: todoStorage.uid++,
				title: value,
				completed: false
			});
			this.newTodo = '';
		},
		removeTodo(todo) {
			this.todos.splice(this.todos.indexOf(todo), 1);
		},
		editTodo(todo) {
			this.beforeEditCache = todo.title;
			this.editedTodo = todo;
		},
		doneEdit(todo) {
			if (!this.editedTodo) {
				return;
			}
			this.editedTodo = null;
			const title = todo.title.trim();
			if (title) {
				todo.title = title;
			} else {
				this.removeTodo(todo);
			}
		},
		cancelEdit(todo) {
			this.editedTodo = null;
			todo.title = this.beforeEditCache;
		},
		removeCompleted() {
			this.todos = filters.active(this.todos);
		}
	},
	directives: {
		'todo-focus'(element, binding) {
			if (binding.value) {
				element.focus();
			}
		}
	}
});
function onHashChange() {
	const visibility = window.location.hash.replace(/#\/?/, '');
	if (filters[visibility]) {
		app.visibility = visibility;
	} else {
		window.location.hash = '';
		app.visibility = 'all';
	}
}
window.addEventListener('hashchange', onHashChange);
onHashChange();
app.$mount('.todoapp');

サンプル001■Vue.js + ES6: Vue TodoMVC 05

See the Pen Vue.js + ES6: Vue TodoMVC 05 by Fumio Nonaka (@FumioNonaka) on CodePen.

[*2] ハイフン「-」は識別子に使えません。そのため、メソッド名は文字列にしています。

[*3] カスタムディレクティブには標準では、何が起こったときに呼び出すかというイベントを決めたフック関数を定めます。けれど、イベントなしに直に与えた関数は、省略記法としてbind(要素を紐づけしたとき)とupdate(要素のコンポーネントが更新されたとき)に呼び出されます。


作成者: 野中文雄
更新日: 2019年11月15日 構文をECMAScript 2015に改め、本文も修正のうえ、項04は削除。サンプルはCodePenに差し替えた。
更新日: 2017年12月26日 04「ECMAScript 6の構文に書き替える」を追加。
作成日: 2017年7月9日


Copyright © 2001-2017 Fumio Nonaka.  All rights reserved.