[Javascript] 入力フォーム以外の子要素にfocusとblurイベントを適用させる方法

2025/09/13

Javascript

t f B! P L
eyecatch プログラミングの仕事をしていると、いつも新たな課題を与えてくれて、非常におもしろいパズルゲームを謎解きしている感覚で、楽しくて仕方がありません。 今回は、とある管理システムで、データベースの検索結果を表示するというページで、同時にデータベースの値を書き換えるという、普通にやったら、 入力フォームのバケモノページになってしまいがちなモノを、比較的便利に適用できてしまう方法と、その時のイベントを想定通りに稼働させる方法を独自の視点で考えてみたので、 安定的なシステム開発と、UIと、画期的なデータ活用に興味がある(または好き)な人は、ぜひブログを読んでご堪能ください。

データベースのTableシステムは平成の当たり前だったが、令和ではオワコン

カオスなデータベースで、データの読み書きをする場合、次のようなinputだらけの入力ページを作りがちです。
もちろん、必要最低限で、データベースの値を変更させると言う、デザインセンスもなければ、バリデーションを個別で扱うことになる、最近ではやってはいけないシステム構築の一つに挙げられる行為です。(ウソです) また、こうしたデータ登録は、新規登録として、レコードデータの追加で使われがちなページですが、 データベース内容をマトリクスTableで表示するようになると、もはやカオスな状態になります。
※表示しているテーブルは、サンプルで作ったモノです。

HTMLの値を直接書き込むcontenteditable属性

カオスなテーブルにも適用できる、contenteditableteという属性は、親であるtableタグにセットしておくと、その内部のセルが書き込み可能な状態になります。

contenteditableデモ

次のテーブルは、それぞれのセルの内容を書き換える事ができます(保存はされないけど)
# A B C D
1 りんご バナナ ぶどう スイカ
2 キウイ みかん もも メロン
3 マンゴー レモン ブルーベリー ラズベリー

contenteditableを利用したデータ保存方法

文字の入力ができたら、その情報をサーバーにPOSTする事で、データを更新する事が可能になる。

方法1. 送信ボタンでSUBMIT

送信ボタンを設置して、そのボタンを押す事で、イベント発火させて、 テーブル内のセルのテキストを取得してそれぞれのテーブルカラム情報を書き換える事ができます。 テーブルの列を、テーブルのレコードに見立てて、値を全て書き換える事が可能になりますが、 デメリットとしては、データを全て送信してしまうことになります。 編集をしたレコードに書き換えフラグをTRタグに属性などで設置することで、全データ送信は防げるかもしれません。

方法2. セル更新単位でPOST

セル単位でデータ送信する事ができたら、かなりリアルタイムにテーブル更新をする事が可能になります。 送信忘れや、画面フリーズなどに対して、最小限の被害で食い止めることもできますが、 こちらもデメリットはあって、レコード単位じゃなくて、個別セル(カラム)単位のバリデーションが必須になるので、 Javascriptでバリデーションシステムを作っておかないと、不正登録が防げなくなります。 また、複数の別カラムの値などを参照する場合などのバリデーションは、少し複雑な処理になる可能性があります。 でも、個人的にはこの方法を今回は採用することにしたいと思います。

イベントのfocusとblurの扱いずらい点

contenteditable属性をセットした、tableタグ(今回はtbodyタグ)は、onfocusや、onblurのなどのイベントが使えます。 でも、onchangeなどの使えないイベントもあるので、そういうものを使用したい場合は、inputタグを使用する必要があります。 ここで考えたいのは、テーブルの内容は、できればテーブル構造によって変わってっくるので、イベント設置は、tableやtbodyという、親タグに設置したいと考えた。 こうすることで、内包するセルの書き換えが行われたら(今回はblurしたら)データをバリデートしてサーバーに送信することで、リアルタイムテーブル書き換えシステムが作れてしまいます。 そして、focusとblueをセットしてみたところ・・・ イベント発火は、内包セルではなく、イベント設置したtableやtbodyタグで行われてしまうので、どのセルが書き変わったのかわからないと言う状態が見つかりました。 そらそうなんですけど、イベント発火時の、event.targetは、イベントハブリング(イベント伝播)のような事象が発生してしまいます。 せめて、修正したセル情報を返してくれるだけでいいんですが、それができない事象が確認できました。

テーブル内のセルを正確にキャッチする方法

上記(tableタグやtbodyタグにイベント設置)の場合、内包するセルを特定するには、次のように処理することで、セルのイベント取得が可能になります。

onfocus, onblur サンプル

# A B C D
1 りんご バナナ ぶどう スイカ
2 キウイ みかん もも メロン
3 マンゴー レモン ブルーベリー ラズベリー
--
class Main{ target = document.querySelector("#focus_blur_sample tbody") result = document.querySelector("#focus_blur_result") constructor(){ console.log(this.target) this.target.addEventListener("focus", this.focus_cell.bind(this)) this.target.addEventListener("blur" , this.blur_cell.bind(this)) } focus_cell(){ requestAnimationFrame(() => { const cell = this.get_focus_cell(); this.result.textContent = `[focus] `+ cell.textContent }) } blur_cell(){ const cell = this.get_blur_cell(); this.result.textContent = `[blur] `+ cell.textContent } // Focus get_focus_cell(e){ const selection = window.getSelection(); // selection が存在しない、または rangeCount が 0 の場合 if (!selection || selection.rangeCount === 0) { // 最初の td にカーソルを置く const firstCell = e.target.querySelector("td, th"); if (firstCell) { const range = document.createRange(); range.selectNodeContents(firstCell); range.collapse(true); // 範囲を先頭に selection.removeAllRanges(); selection.addRange(range); console.log("フォーカス中のセル(自動セット):", firstCell); } return null; } // カーソル位置からセルを取得 let node = selection.getRangeAt(0).startContainer; if (node.nodeType === Node.TEXT_NODE) node = node.parentElement; return node.closest("td, th"); } // Blur get_blur_cell(){ // 現在の選択範囲を取得 const selection = document.getSelection() if (!selection.rangeCount) return // 選択範囲の開始ノードを取得 let node = selection.getRangeAt(0).startContainer // テキストノードの場合は親要素を取得 if (node.nodeType === Node.TEXT_NODE) { node = node.parentElement } // 最も近い td 要素を取得 return node.closest("td, th") } } switch(document.readyState){ case "complete": case "inreactive": new Main();break default: window.addEventListener("DOMContentLoaded", () => new Main()) }

ちょっと解説

focusイベントの中で、requestAnimationFrame()を使っているのは、 focusイベントが発火した直後では、getSelection()をしても、選択済みの項目が取得できないため、ワンテンポずらして取得するようにしています。

隣のセルに移動した時に、イベント発火しない問題

問題が発生しました! 上記のイベントは、tbodyタグのfocusとblurをイベント発火させてしまうため、 セルが選択されている状態で、別のセルを選択しても、イベントが発火しません。 いちいち、テーブルからカーソルを外さないといけませんね。 こりゃあ、大問題だ!。 ・・・ということで、selectionchangeイベントを使って、focusとblurと同じイベントと、その対象セルを取得するコードを作ってみました。

selectionchangeイベントサンプル

# A B C D
1 りんご バナナ ぶどう スイカ
2 キウイ みかん もも メロン
3 マンゴー レモン ブルーベリー ラズベリー
--
class Main{ target = document.querySelector("#selectionchange_sample tbody") result = document.querySelector("#selectionchange_result") constructor(){ document.addEventListener("selectionchange", this.event_selectionchange.bind(this)) } event_selectionchange(){ // 対象のセルを取得(カーソル移動先) const cell = this.get_current_cell() // 同じ要素内の場合は処理しない if(this.active_cell === cell){return} // blur処理 if(this.active_cell){ this.blur_cell(this.active_cell) this.active_cell = null this.value_of_focus = null } // 修正対象以外は処理しない if(!cell || !cell.closest(`[contenteditable="true"]`)){return} // 次の処理のためにcellを保存 this.active_cell = cell // focus処理 this.focus_cell(cell) } // Blur get_current_cell(){ // 現在の選択範囲を取得 const selection = document.getSelection() if (!selection.rangeCount) return // 選択範囲の開始ノードを取得 let node = selection.getRangeAt(0).startContainer // テキストノードの場合は親要素を取得 if (node.nodeType === Node.TEXT_NODE) { node = node.parentElement } // 最も近い td 要素を取得 return node.closest("td, th") } focus_cell(cell){ // 非表示文字(はみだし)を表示 cell.style.setProperty("text-overflow", "unset", "") this.result.textContent += `[focus] `+ cell.textContent } blur_cell(cell){ // はみ出し文字を非表示にする cell.style.removeProperty("text-overflow") const value = cell.innerHTML this.result.textContent = ` [blur] `+ value } } switch(document.readyState){ case "complete": case "inreactive": new Main();break default: window.addEventListener("DOMContentLoaded", () => new Main()) }

解説

onselectionchangeイベントは、ページ内のカーソルが移動した時に発火します。 これまで、focusとblurの2つのイベントでやっていたことを、selectionchangeという、1つのイベントで処理を行うようにします。 選択したセルを一旦キャッシュ保持する仕様にすることで、キャッシュがある場合はblurを実行してからfocus、キャッシュがない場合はfocusのみを実行するようにすることで、focus - blurイベントとほぼ同じ挙動を作り出す事ができます。(正直、focusはいらないんですが、キャッシュ保持のために使います) ページ内をクリックするたびに発生することになるんですが、上位の要素で、contenteditable="true" がない場合は、処理をしないようにしてバリデートしています。 また、今回の処理には入っていませんが、onchange的な要素として、セル内のテキストが変更されていなければ、blurイベントを発火させないようにすることで、 無駄な更新パケットの発生を防ぐことも可能です。 onchangeイベントの実装は、キャッシュの文字列と、blurの文字列を比較して、同じでなければ、onchangeイベントを実行するという仕様でできるでしょう。

あとがき

仕事で、ちょろっと作った今回のテーブル書き換えUIですが、 管理画面の革命を起こすんじゃないかと思うぐらい画期的な内容なんじゃないかと自画自賛しています。 システム管理者の人でUIまでを管理している人であれば、わかりますよね。 とにかく、自分のための備忘録でもあるので、今回のブログはかなり時間をかけて書いてしまいました。

人気の投稿

このブログを検索

ごあいさつ

このWebサイトは、独自思考で我が道を行くユゲタの少し尖った思考のTechブログです。 毎日興味がどんどん切り替わるので、テーマはマルチになっています。 もしかしたらアイデアに困っている人の助けになるかもしれません。

ブログ アーカイブ