サイトトップ

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

HTML5テクニカルノート

Vue + <script setup> + TypeScript: TodoMVC 06 項目のテキストをダブルクリックで再編集する


Vue + <script setup> + TypeScriptの構文で、公式サイト「TodoMVC」の例をつくるシリーズ第6回です。Todoリストの基本的な機能は揃ってきました。さらに加えたいのは、すでに入力したTodo項目をダブルクリックで再編集できるようにすることです。項目をいったん削除して入力し直すという手間が省けるでしょう。

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

Todo項目を書き替えるときもボタンは使いません。項目テキスト(<label>要素)のダブルクリックで進めます。リスナーを定めるイベントはv-on:dblclick(省略記法@dblclick)です。リスナーメソッド(editTodo())は、つぎのようにイベント(event: 'editTodo')ともにTodo項目のオブジェクト(todo)を親コンポーネントに送ります。

src/components/TodoItem.vue

<script setup lang="ts">

interface Emits {

	(event: 'editTodo', todo: Todo): void;
}

const editTodo = () => {
	emit('editTodo', props.todo);
};
</script>

<template>
	<div class="view">

		<!-- <label>{{ todo.title }}</label> -->
		<label @dblclick="editTodo">{{ todo.title }}</label>

	</div>
</template>

親モジュールsrc/components/TodoList.vueがダブルクリックのイベント(editTodo)受け取ると、呼び出されるのがリスナーメソッド(editTodo())です。引数として受け取ったTodo項目(todo)は、refオブジェクト(editedTodo)に定められます。これで、親コンポーネントはどのTodo項目が編集中か知ることができるのです(編集中の項目がない場合の値はnull)。子コンポーネントTodoItemの親要素(<li>)には、つぎのようにクラスeditingをバインディング(:class)に加えました。なお、新たに差し込んだ項目編集用の子コンポーネント(TodoEdit)は、のちほどつくります。プロパティとしてtodoをバインディングしたのは、ダブルクリックされた項目データを編集するためです。

src/components/TodoList.vue

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

import TodoEdit from './TodoEdit.vue';

const editedTodo = ref<Todo | null>(null);

const editTodo = (todo: Todo) => {
	editedTodo.value = todo;
};

</script>

<template>
	<section class="main">

		<ul class="todo-list">
			<!-- :class="['todo', { completed: todo.completed }]" -->
			<li
				v-for="todo in filteredTodos"
				:class="[
					'todo',
					{ completed: todo.completed, editing: todo == editedTodo },
				]"
				:key="todo.id"
			>
				<!-- <TodoItem :todo="todo" @removeTodo="removeTodo" @done="done" /> -->
				<TodoItem
					:todo="todo"
					@removeTodo="removeTodo"
					@done="done"
					@editTodo="editTodo"
				/>
				<TodoEdit :todo="todo" />
			</li>
		</ul>
	</section>
</template>

Todo項目のダブルクリックにより、子コンポーネントTodoItemTodoEditの表示を切り替えなければいけません。そのために加えたのが、親コンポーネントTodoListにバインディングしたeditingクラスです。各親子コンポーネントには、CSSのクラスがつぎのように定められています。

これらのクラスの扱いを抜き出したのが、つぎのindex.cssの記述です。viewクラスの要素はすでに見てきたとおり、はじめから表示されていました。それに対して、editクラスはもとが非表示です。ここに、Todo項目のダブルクリックでeditingクラスのバインディングが変わると、要素の表示と非表示は切り替わるのです。

https://unpkg.com/todomvc-app-css@2.4.2/index.css

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

}

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

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

新たなモジュールsrc/components/TodoEdit.vueの記述は、動作を確認するため、いったんつぎの記述で済ませておきましょう。Todo項目をダブルクリックすると、編集用のテキスト入力フィールドに表示が切り替わるはずです(図001)。

src/components/TodoEdit.vue

<script setup lang="ts">
import type { Todo } from '../App.vue';

interface Props {
	todo: Todo;
}
defineProps<Props>();
</script>

<template>
	<input :id="`edit-${todo.id}`" class="edit" type="text" :value="todo.title" />
</template>

図001■Todo項目をダブルクリックすると編集用のテキスト入力フィールドが表れる

図001

Todo項目単体表示のモジュールsrc/components/TodoItem.vueには、今回これ以上の手は加えません。つぎのコード001に記述全体をまとめましょう。

コード001■Todo項目単体表示のモジュール

src/components/TodoItem.vue

<script setup lang="ts">
import type { Todo } from '../App.vue';

interface Props {
	todo: Todo;
}
interface Emits {
	(event: 'removeTodo', todo: Todo): void;
	(event: 'done', todo: Todo, completed: boolean): void;
	(event: 'editTodo', todo: Todo): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const removeTodo = () => {
	emit('removeTodo', props.todo);
};
const onChange = () => {
	emit('done', props.todo, !props.todo.completed);
};
const editTodo = () => {
	emit('editTodo', props.todo);
};
</script>

<template>
	<div class="view">
		<input
			type="checkbox"
			class="toggle"
			:checked="todo.completed"
			@change="onChange"
		/>
		<label @dblclick="editTodo">{{ todo.title }}</label>
		<button class="destroy" @click="removeTodo" />
	</div>
</template>

ノート01■DOMイベントハンドラ

Vue公式サイトの「メソッドハンドラー」には、とくにdblclickイベントは記載されていません。けれど、JavaScriptネイティブのDOMイベントはサポートされます(「Why not add v-on:doubleClick」参照)。dblclickイベントもそのひとつです。

メソッドハンドラーは、トリガーとなるネイティブのDOMイベントオブジェクトを自動的に受け取ります(「メソッドハンドラー」) 。

02 [enter]キーでTodo項目の編集を確定する

編集した項目テキストはモジュールsrc/components/TodoEdit.vueのテンプレートに、つぎのように@inputイベントのリスナーメソッド(onInput())を加え、refオブジェクト(editedTitle)に反映しました。項目の追加と同じく、編集の確定は[enter]キーです。@keypress.enterイベントで、リスナーメソッド(doneEdit())から親のコンポーネントにイベント(event: 'doneEdit')と新たな項目テキスト(editedTitle)を送ります。

src/components/TodoEdit.vue

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

interface Emits {
	(event: 'doneEdit', editedTitle: string): void;
}

const emit = defineEmits<Emits>();
const editedTitle = ref<string | null>(null);
const doneEdit = ({ target }: Event) => {
	if (!(target instanceof HTMLInputElement)) return;
	editedTitle.value = target.value;
	emit('doneEdit', editedTitle.value);
};
const onInput = ({ target }: Event) => {
	if (target instanceof HTMLInputElement) {
		editedTitle.value = target.value;
	}
};
</script>

<template>
	<!-- <input :id="`edit-${todo.id}`" class="edit" type="text" :value="todo.title" /> -->
	<input
		:id="`edit-${todo.id}`"
		class="edit"
		type="text"
		:value="todo.title"
		@input="onInput"
		@keypress.enter="doneEdit"
	/>
</template>

親モジュールsrc/components/TodoList.vueが子コンポーネント(TodoEdit)から受け取ったイベント(doneEdit)のハンドラメソッドがdoneEdit()です。引数(todoTitle)が有効な文字列であれば、Todo項目のテキスト(title)が書き替わります。実質テキストがない(空文字列を含む)場合は、Todo項目そのものを削除することにしました。これで、項目をダブルクリックして書き替えたテキストが、[enter]キーで確定できるようになったのです(図002)。

src/components/TodoList.vue

<script setup lang="ts">

const doneEdit = (todoTitle: string) => {
	if (!editedTodo.value) return;
	const title = todoTitle.trim();
	if (title) {
		editedTodo.value.title = title;
	} else {
		removeTodo(editedTodo.value);
	}
	editedTodo.value = null;
};

</script>

<template>
	<section class="main">

		<ul class="todo-list">
			<li

			>

				<!-- <TodoEdit :todo="todo" /> -->
				<TodoEdit :todo="todo" @doneEdit="doneEdit" />
			</li>
		</ul>
	</section>
</template>

図002■編集したテキストが[enter]キーで差し替わる

図002

03 [esc]キーでTodo項目の編集を中止する

編集したTodo項目のテキストを[enter]で確定せず、[esc]キーを押したときはとり止めることにしましょう。モジュールsrc/components/TodoEdit.vueのテンプレートに加えるのは、つぎのような@keyup.escイベントのリスナーメソッド(cancelEdit())です。<input type="text">要素のテキスト(valueプロパティ)はTodo項目オブジェクト(todo)の文字列(title)に戻し、親コンポーネントには取り消しのイベント(event: 'cancelEdit')を送ります。

src/components/TodoEdit.vue

<script setup lang="ts">

interface Emits {
	(event: 'cancelEdit'): void;

}
// defineProps<Props>();
const props = defineProps<Props>();

const cancelEdit = ({ target }: Event) => {
	if (!(target instanceof HTMLInputElement)) return;
	target.value = props.todo.title;
	emit('cancelEdit');
};

</script>

<template>
	<input

		@keyup.esc="cancelEdit"
	/>
</template>

イベント(doneEdit)を受け取った親モジュールsrc/components/TodoList.vueが呼び出すのは、リスナーメソッドcancelEdit()です。編集中の項目データを示すrefオブジェクト(editedTodo)の値はnull、つまりなしに戻ります。項目のデータはもとのまま変わりません。そして、表示するTodo項目単体の子コンポーネントは、クラスバインディングにより編集中(TodoEdit)から入力済み(TodoItem)に切り替わるのです。

src/components/TodoList.vue

<script setup lang="ts">

const cancelEdit = () => {
	editedTodo.value = null;
};

</script>

<template>
	<section class="main">

		<ul class="todo-list">
			<li

			>

				<!-- <TodoEdit :todo="todo" @doneEdit="doneEdit" /> -->
				<TodoEdit :todo="todo" @cancelEdit="cancelEdit" @doneEdit="doneEdit" />
			</li>
		</ul>
	</section>
</template>

書き上がったTodo項目編集(src/components/TodoEdit.vue)とリスト表示(src/components/TodoList.vue)のモジュールの記述全体を、つぎのコード002にまとめました。

コード002■Todo項目編集とリスト表示のモジュール

src/components/TodoEdit.vue

<script setup lang="ts">
import { ref } from 'vue';
import type { Todo } from '../App.vue';

interface Props {
	todo: Todo;
}
interface Emits {
	(event: 'cancelEdit'): void;
	(event: 'doneEdit', editedTitle: string): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const editedTitle = ref<string | null>(null);
const cancelEdit = ({ target }: Event) => {
	if (!(target instanceof HTMLInputElement)) return;
	target.value = props.todo.title;
	emit('cancelEdit');
};
const doneEdit = ({ target }: Event) => {
	if (!(target instanceof HTMLInputElement)) return;
	editedTitle.value = target.value;
	emit('doneEdit', editedTitle.value);
};
const onInput = ({ target }: Event) => {
	if (target instanceof HTMLInputElement) {
		editedTitle.value = target.value;
	}
};
</script>

<template>
	<input
		:id="`edit-${todo.id}`"
		class="edit"
		type="text"
		:value="todo.title"
		@input="onInput"
		@keypress.enter="doneEdit"
		@keyup.esc="cancelEdit"
	/>
</template>

src/components/TodoList.vue

<script setup lang="ts">
import { ref } from 'vue';
import type { Todo } from '../App.vue';
import TodoEdit from './TodoEdit.vue';
import TodoItem from './TodoItem.vue';

interface Props {
	allDone: boolean;
	filteredTodos: Todo[];
}
interface Emits {
	(event: 'removeTodo', todo: Todo): void;
	(event: 'done', todo: Todo, completed: boolean): void;
	(event: 'toggleAll', checked: boolean): void;
}
defineProps<Props>();
const emit = defineEmits<Emits>();
const editedTodo = ref<Todo | null>(null);
const removeTodo = (todo: Todo) => {
	emit('removeTodo', todo);
};
const done = (todo: Todo, completed: boolean) => {
	emit('done', todo, completed);
};
const editTodo = (todo: Todo) => {
	editedTodo.value = todo;
};
const cancelEdit = () => {
	editedTodo.value = null;
};
const doneEdit = (todoTitle: string) => {
	if (!editedTodo.value) return;
	const title = todoTitle.trim();
	if (title) {
		editedTodo.value.title = title;
	} else {
		removeTodo(editedTodo.value);
	}
	editedTodo.value = null;
};
const onChange = ({ target }: Event) => {
	if (!(target instanceof HTMLInputElement)) return;
	emit('toggleAll', target.checked);
};
</script>

<template>
	<section class="main">
		<input
			id="toggle-all"
			type="checkbox"
			class="toggle-all"
			:checked="allDone"
			@change="onChange"
		/>
		<label for="toggle-all">Mark all as complete</label>
		<ul class="todo-list">
			<li
				v-for="todo in filteredTodos"
				:class="[
					'todo',
					{ completed: todo.completed, editing: todo == editedTodo },
				]"
				:key="todo.id"
			>
				<TodoItem
					:todo="todo"
					@removeTodo="removeTodo"
					@done="done"
					@editTodo="editTodo"
				/>
				<TodoEdit :todo="todo" @cancelEdit="cancelEdit" @doneEdit="doneEdit" />
			</li>
		</ul>
	</section>
</template>

ソース01■TodoMVC 06 項目のテキストをダブルクリックで再編集する

ノート02■@keyup.esc

テキストの入力を[return]/[Enter]キーで確定しようとしたとき、@keyupイベントで扱うと、日本語入力の場合不都合がありました(「keypresskeyup」)。[esc]キーでも問題は同じです。漢字変換候補を表示したあと、変換を戻すつもりが、入力の取り消しとみなされてしまいます。けれど、この場合イベントを@keypressにしても変わりません。当面、解決策は見当たらない状況です。

Vue + <script setup> + TypeScript: TodoMVCシリーズ


作成者: 野中文雄
作成日: 2022年12月22日


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