やりたいこと
EnterキーでTabキーを押したようにコントロールのフォーカスを移動したい。
JavaScriptで実装します。
仕様
-
フォーカス遷移対象要素
-
<a><input><select><textarea><button>のタグ要素 -
tabindex属性が設定された要素 - ただし、以下の場合は対象外
-
tabindex属性が負数の要素 -
display: noneの要素
-
-
-
Enterキー
- 順送り
- 次のコントロールにフォーカスを移動
- 最後のコントロールからは最初のコントロールに移動
- 要素の
tabindex昇順(設定されていない要素は0とみなす)
- 順送り
-
Shift+Enter
- 逆送り
- 前のコントロールにフォーカスを移動
- 最初のコントロールからは最後のコントロールに移動
- 要素の
tabindex降順(設定されていない要素は0とみなす)
- 逆送り
-
Alt+Enter
- 通常の
Enterキーイベント-
textarea要素- カーソル位置に改行を挿入
-
onclick属性が設定された要素- 要素のクリックイベントを発生
-
- 通常の
ソース
// フォーカス遷移対象要素
let tabFocusElements = createTabFocusElements();
// フォーカス遷移対象要素作成
function createTabFocusElements() {
// フォーカス遷移対象要素をフィルタリング
let elements = filterTabFocusElements(document.querySelectorAll("*"));
// tabIndex属性でソート(ない場合は0とみなす)
elements.sort((a, b) => {
if (a.tabIndex === 0) return 1;
if (b.tabIndex === 0) return -1;
return a.tabIndex - b.tabIndex;
});
return elements;
}
// フォーカス遷移対象要素フィルタリング
function filterTabFocusElements(nodeList) {
return Array.from(nodeList).filter(target => {
// エレメントノード以外を除外
if (target.nodeType !== Node.ELEMENT_NODE) {
return false;
}
// <a><input><select><textarea><button>または正のtabindex属性を持つ場合は対象
const targetTags = ["a", "input", "select", "textarea", "button"];
return targetTags.includes(target.tagName.toLowerCase()) || (target.hasAttribute("tabindex") && target.tabIndex >= 0)
});
}
// keydownイベントリスナ
window.addEventListener("keydown", event => {
// Enterキー押下の場合
if (event.key === "Enter") {
// 通常のキーイベントを抑止
event.preventDefault();
// イベント発生元要素がリスト内のどこにあるか
let arrayIndex = tabFocusElements.indexOf(event.target);
// イベント発生元要素がリスト内に存在する場合
if (arrayIndex >= 0) {
// <textarea>でのAlt+Enter
if (event.target.tagName.toLowerCase() === "textarea" && event.altKey) {
// 通常のキーイベント(改行)
// 現在のキャレット位置を取得
let currentSelectionStart = event.target.selectionStart;
// キャレット位置に改行を挿入
event.target.value = event.target.value.substr(0, currentSelectionStart) + "\n" + event.target.value.substr(event.target.selectionEnd);
// キャレット位置を元の位置に変更
event.target.selectionStart = currentSelectionStart + 1;
event.target.selectionEnd = currentSelectionStart + 1;
return;
}
// onclick属性が設定された要素でのAlt+Enter
if (event.target.onclick !== null && event.altKey) {
// 通常のキーイベント(クリック)
event.target.click();
return;
}
let nextElement;
// Enter(順送り)
if (!event.shiftKey) {
// イベント発生要素以外のフォーカス遷移対象要素を昇順に取得
for (let i = 1; i < tabFocusElements.length; i++) {
if (arrayIndex + i < tabFocusElements.length) {
// 最後の要素まで
nextElement = tabFocusElements[arrayIndex + i];
} else {
// 最後の要素以降は最初の要素に戻る
nextElement = tabFocusElements[arrayIndex + i - tabFocusElements.length]
}
// display:noneでない場合はフォーカスして終了
if (nextElement.style.display !== "none" && (nextElement.offsetParent !== null || nextElement.style.position === "fixed")) {
nextElement.focus();
break;
}
}
}
// Shift+Enter(逆送り)
if (event.shiftKey) {
// イベント発生要素以外のフォーカス遷移対象要素を降順に取得
for (let i = 1; i < tabFocusElements.length; i++) {
if (arrayIndex - i >= 0) {
// 最初の要素まで
nextElement = tabFocusElements[arrayIndex - i];
} else {
// 最初の要素以降は最後の要素に戻る
nextElement = tabFocusElements[arrayIndex - i + tabFocusElements.length]
}
// display:noneでない場合はフォーカスして終了
if (nextElement.style.display !== "none" && (nextElement.offsetParent !== null || nextElement.style.position === "fixed")) {
nextElement.focus();
break;
}
}
}
}
}
});
// mutation observer
const observer = new MutationObserver(mutations => {
MUTATIONS: for (let mutation of mutations) {
// 追加/削除された要素の判定
if (filterTabFocusElements(mutation.addedNodes).length > 0 || filterTabFocusElements(mutation.removedNodes).length > 0 ) {
// 追加/削除された要素がフォーカス遷移対象の場合は再作成
createTabFocusElements();
break MUTATIONS;
}
// 追加された要素の子孫要素の判定
for (let addedNode of mutation.addedNodes) {
// エレメントノード以外を除外
if (addedNode.nodeType === Node.ELEMENT_NODE) {
if (filterTabFocusElements(addedNode.querySelectorAll("*")).length > 0) {
// 追加された要素がフォーカス遷移対象の場合は再作成
createTabFocusElements();
break MUTATIONS;
}
}
}
// 削除された要素の子孫要素の判定
for (let removeNode of mutation.removedNodes) {
// エレメントノード以外を除外
if (removeNode.nodeType === Node.ELEMENT_NODE) {
if (filterTabFocusElements(removeNode.querySelectorAll("*")).length > 0) {
// 削除された要素がフォーカス遷移対象の場合は再作成
createTabFocusElements();
break MUTATIONS;
}
}
}
}
});
// mutation observer監視設定
const config = {
childList: true,
subtree: true
};
// mutation observer監視開始
observer.observe(document.body, config);
解説
処理の流れは単純明快です。
-
Enterキーでフォーカス遷移させる対象要素を配列として取得し、tabindex順に並び変え -
Enterキーのkeydownイベントを検知したら配列内で発生元を探す - 発生元があれば、配列の次/前の要素が
display: noneでない場合はフォーカス - 配列の次/前の要素が
display: noneだった場合、さらに次の要素へ
ただし、textareaやbuttonなどで通常のEnterキーイベントを実行できなくなってしまうため、Alt+Enterにそれらの処理を割り当て直しています。
querySelectorAllへのfilterやsort(Array.from(nodeList))
querySelectorAllで取得できるのは配列ではなくNodeListオブジェクトです。ForEachなどのイテレータメソッドは使えますが、filterやsortなど今回の処理で必要なメソッドが使用できません。
そこで、Array.fromメソッドを使って配列変換を行ってから上記のメソッドを利用しています。
コード内ではそのままメソッドチェーンで.filter()と繋げています。
<textarea>の改行再現(event.target.selectionStart/selectionEnd)
<textarea>内でAlt+Enterを発生させた時に、通常のEnterキーイベントである「現在のキャレット位置に改行を挿入する」を実現するために使用しています。
上記を実現するためにはイベント発生時の「現在のキャレット位置」を識別する必要がありますが、この値はこれら2つの属性値に格納されています。
2つあるのは、<textarea>内で文字列を範囲選択している時のため。event.target.selectionStartには範囲選択の開始位置が、event.target.selectionEndには終了位置が格納されます。範囲選択していない場合は両方とも同じ値。
この値を元にevent.target.valueに格納されている<textarea>の文字列をsubstrメソッドで分割し、改行コード\nで結合しています。
また、これらの属性値に対して数値を代入することで、キャレット位置をプログラム側から変更することができます。これにより改行の直後にキャレット位置を移動させることで、通常のキーイベントを実現できます。
ただし、残念ながらCtrl+Zでの操作戻しはできません。
display: noneへの対応(offsetParent, style.position)
最初はfilterでdisplay: noneの要素を除外していたのですが、これだと再表示された時にフォーカス遷移の対象にならない・非表示にされた時にフォーカスロストしてしまうという問題がありました。
そのため、対象としては取得しておいて遷移時にdisplay: noneかどうかを判定する方式に切り替えました。
また、単純に要素がdisplay: noneかどうかを判定するだけでは親要素ごと非表示にされていた場合に検出できないため、offsetParentとstyle.positionを使って追加判定をしています。
詳細は以下参照。
【JavaScript】親要素の display:none の影響で非表示になりうる要素が表示状態か否か識別する方法
追加/削除要素への対応(mutation observer)
後からスクリプトで追加された要素はフォーカス遷移の対象になりません。また、フォーカス遷移対象だった要素がスクリプトで削除された場合はエラーになります。(たぶん)
このため、要素の追加/削除が発生した場合はフォーカス遷移対象要素リストの再作成が必要になります。
ソースの例では、mutation observerを使って要素が追加/削除された場合にその要素自身と子孫要素を判定し、必要に応じてcreateTabFocusElements()を再実行させています。
エレメントノード以外を除外(removeNode.nodeType)
mutation oberverのaddedNodes removeNodesには、<textarea>で入力した文字列も含まれてしまいます。(<textarea>入力文字列</textarea>となって入力文字列がテキストノード扱いになってしまう)また、追加されたノードにコメントなどがあった場合、これらも含まれます。
こういった通常のエレメント以外のノードを除外したい場合、ノードのnodeTypeを判別します。nodeTypeは整数値で表現されますが、nodeインターフェースのプロパティに値が指定されています。
一般のユーザに見える通常のエレメントはnode.ELEMENT_NODE(1)になるので、これ以外を除外してしまえばOKです。
詳細は以下参照。
Node: nodeType プロパティ
参考にしたページ
【JavaScript】親要素の display:none の影響で非表示になりうる要素が表示状態か否か識別する方法
Node: nodeType プロパティ