HTML5テクニカルノート
Vue + <script setup> + TypeScript: TodoMVC 07 フォーカスをコントロールする
- ID: FN2212005
- Technique: ECMAScript 2015
- Package: Vue 3.2
Vue + <script setup> + TypeScriptの構文で、公式サイト「TodoMVC」の例をつくるシリーズ第7回です。Vue公式サイトの作例TodoMVCの動きはほぼでき上がりました。けれど、まだ気になるところがあります。それは、テキスト入力フィールドのフォーカスの扱いです。ふたつの不具合を直して仕上げます。
01 ダブルクリックで編集しているテキストからフォーカスを外したらフィールドを閉じる
ひとつ目は、ダブルクリックで始めたTodo項目単体の編集が、[enter]キーか[esc]キーを押さないかぎり終わらないことです。編集中の項目は途中にして開いたまま、新たなTodo項目を追加するフィールドにテキストが入力できてしまいます
図001■Todo項目を編集中に新規項目の入力ができる

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シリーズ
- TodoMVC 01 リスト項目の表示と追加
- TodoMVC 02 リスト項目の削除とスタイル変更
- TodoMVC 03 算出プロパティとデータのローカルへの保存
- TodoMVC 04 リスト表示する項目を選び出して切り替える
- TodoMVC 05 チェックをまとめてオン/オフしたり削除する
- TodoMVC 06 項目のテキストをダブルクリックで再編集する
- TodoMVC 07 フォーカスをコントロールする
- TodoMVC 08 要素にアニメーションを加える
- TodoMVC 09 コンポーネントからロジックをコンポーザブル関数に切り分ける
作成者: 野中文雄
作成日: 2022年12月30日
Copyright © 2001-2020 Fumio Nonaka. All rights reserved.