サイトトップ

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

HTML5テクニカルノート

Vue + <script setup> + TypeScript: TodoMVC 07 フォーカスをコントロールする


Vue + <script setup> + TypeScriptの構文で、公式サイト「TodoMVC」の例をつくるシリーズ第7回です。Vue公式サイトの作例TodoMVCの動きはほぼでき上がりました。けれど、まだ気になるところがあります。それは、テキスト入力フィールドのフォーカスの扱いです。ふたつの不具合を直して仕上げます。

01 ダブルクリックで編集しているテキストからフォーカスを外したらフィールドを閉じる

ひとつ目は、ダブルクリックで始めたTodo項目単体の編集が、[enter]キーか[esc]キーを押さないかぎり終わらないことです。編集中の項目は途中にして開いたまま、新たなTodo項目を追加するフィールドにテキストが入力できてしまいます

図001■Todo項目を編集中に新規項目の入力ができる

図001

Todo項目編集のテキストフィールド(<input type="text">要素)からフォーカスを外したら、入力が取り消されるようにしなければなりません。要素がフォーカスを失ったときのイベントは@blurです。つぎのように、src/components/TodoEdit.vueモジュールの<template>に編集キャンセルのイベントリスナー(cancelEdit())を加えました。これで、ダブルクリックで書き替えを始めた入力フィールドからフォーカスを外すと、Todo項目のテキストは編集されないままもとに戻ります。ただし、注意しなければならないのは、項目編集のテキストフィールドにはあらかじめフォーカスを当てておかなければならないことです。ダブルクリックしただけでは、項目編集のコンポーネントに表示が切り替わっただけでフォーカスは与えられません。その場合、@blurイベントが起こらないのです。

src/components/TodoEdit.vue

<template>
	<input


		@blur="cancelEdit"

	/>
</template>

02 Todo項目がダブルクリックされたら編集するテキスト入力フィールドにフォーカスを与える

そこで、Todo項目がダブルクリックされたら、そのテキスト入力フィールドの要素(<input type="text">)にフォーカスを移せばよいでしょう。問題は、ダブルクリックするコンポーネント(src/components/TodoItem.vue)と項目を編集するコンポーネント(src/components/TodoEdit.vue)がそれぞれ別で、切り替わることです。

ここでは、コンポーネントの表示をクラスバインディングで切り替えたときの考え方が使えるでしょう(「Todo項目のダブルクリックで編集のテキスト入力フィールドを出す」)。コンポーネントsrc/components/TodoList.vueは、つぎのように編集中のTodo項目のオブジェクト(todo)をrefオブジェクト(editedTodo)にもち、ふたつが一致する項目にv-bind:class(省略記法:class)でCSSのクラスをバインディングしていました。

src/components/TodoList.vue

<script setup lang="ts">

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

</script>

<template>
	<section class="main">

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

					{ completed: todo.completed, editing: todo == editedTodo },
				]"

			>
				<TodoItem
					:todo="todo"

				/>
				<TodoEdit
					todo="todo"

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

この編集中のTodo項目のrefオブジェクト(editedTodo)を、項目編集のコンポーネント(TodoEdit)にもプロパティとして与えましょう。コンポーネントは、すでに自身のTodo項目のオブジェクト(todo)はプロパティとして受け取っています。つまり、このふたつのオブジェクトが一致したとき、その項目編集のテキスト入力フィールドにフォーカスを当てればよいのです。

src/components/TodoList.vue

<template>
	<section class="main">

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

			>


				<TodoEdit
					:editedTodo="editedTodo"

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

src/components/TodoEdit.vue

<script setup lang="ts">

interface Props {
	editedTodo: Todo | null;

}

</script>

Todo項目リスト表示のモジュールsrc/components/TodoList.vueの記述全体を、つぎのコード001にまとめましょう。

コード001■Todo項目リスト表示のモジュール

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
					:editedTodo="editedTodo"
					:todo="todo"
					@cancelEdit="cancelEdit"
					@doneEdit="doneEdit"
				/>
			</li>
		</ul>
	</section>
</template>

03 カスタムディレクティブを使う

カスタムディレクティブ」を用いれば、コンポーネントと同じライフサイクルに応じて、DOM要素が扱えます。項目編集のコンポーネント(TodoEdit)の状態が変わって、コンポーネントと編集中項目のTodoオブジェクトが一致したとき、その要素(<input type="text">)にフォーカスも当てられるということです。

要素に加えるカスタムディレクティブには、接頭辞v-につづけて任意の名前を定めます。呼び出すフックオブジェクトの変数は、<script setup>構文では、ハイフン(-)は外してvで始まるキャメルケースをお使いください。フックオブジェクトには、ライフサイクルに応じたフック関数が加えられます(「ディレクティブフック」参照)。

ここで用いるのは、つぎのようにupdatedです。関数の第1引数はディレクティブを与えた要素(<input type="text">)、第2引数にはディレクティブの右辺式(editedTodo === todo)の値が受け取られます。第2引数(binding)のvalueプロパティがtrueのとき、第1引数(element)の要素(<input type="text">)の編集は始まったのですから、にフォーカスを当てればよいのです。

src/components/TodoEdit.vue

<script setup lang="ts">

// import type { VNode } from 'vue';
import type { Directive, VNode } from 'vue';

const vTodoFocus: Directive<HTMLInputElement> = {
	updated: (element, binding) => {
		if (binding.value) {
			element.focus();
		}
	},
};
</script>

<template>
	<input

		v-todo-focus="editedTodo === todo"
	/>
</template>

Todo項目のダブルクリックだけで編集の入力フィールド(<input type="text">)がフォーカスされるようになりましたから、テキストには一切触れずともフォーカスを外せば編集は取り消されます。Todo項目単体編集のモジュールsrc/components/TodoEdit.vueの記述全体は、つぎのコード002のとおりです。

コード002■Todo項目単体編集のモジュール

src/components/TodoEdit.vue

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

interface Props {
	editedTodo: Todo | null;
	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;
	}
};
const vTodoFocus: Directive<HTMLInputElement> = {
	updated: (element, binding) => {
		if (binding.value) {
			element.focus();
		}
	},
};
</script>

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

ノート01■カスタムディレクティブを使うのは必要最小限に

カスタムディレクティブは、DOMを直接扱わなければならない場合にかぎって使うことが推奨されています。

カスタムディレクティブはDOMを直接操作することでしか必要な機能を実現できない場合にのみ使用してください。v-bindのような組み込みディレクティブを使用した宣言的なテンプレートは、効率的かつサーバレンダリングフレンドリーです。可能なかぎり組み込みディレクティブを使用することをおすすめします(「はじめに」)。

ソース01■TodoMVC 07 フォーカスをコントロールする

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


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


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