JavaScript エンターキーで移動する。その4
teratail で回答したものより更に加筆
https://teratail.com/questions/325107
- radio の checked は1つだけなので、最後に移動する。
- textarea 内の改行はオートインデントが可能。
- [Shift]+[Enter]は逆順に、[Ctrl]+[Enter]で checked の反転と textarea 内での改行
- datalist 内の summary に対応
- アンカータグの移動に対応
<!DOCTYPE html><title></title><meta charset="utf-8"> <style> a:focus, datalist:focus, input:focus { background: rgba(255,0,0,.2); } </style> <body> <nav> <p>最初に[Tab]でフォーカスを移動した場合テストできる<br> <a href="#C">ABC</a> <a href="#C">DEF</a> <a href="#A">GHI</a><br> </nav> <form> <p>フォーム内のアンカータグを移動する<br> <a href="#C">ABC</a> <a href="#C">DEF</a> <a href="#A">GHI</a> <p>通常の INPUT要素<br> 1.<input name="d0" value=""> 2.<input name="d1" value=""> <p>表示されていない要素<br> 1.<input type="hidden" name="d2"> 2.<input type="hidden" name="d2" style="display:none;"> 3.<input type="hidden" name="d2" style="visibility:hidden;"> 4.<input type="hidden" name="d2" style="opacity: 0;"> <p>INPUT[type=radio]要素の場合<br> <label>1.<input type="radio" name="d3" value="1">abc</label> <label>2.<input type="radio" name="d3" value="2">def</label> <label>3.<input type="radio" name="d3" value="3">ghi</label> <p>INPUT[type=checkbox]要素の場合<br> <label><input type="checkbox" name="d4" value="1">abc</label> <label><input type="checkbox" name="d4" value="2">def</label> <label><input type="checkbox" name="d4" value="3">ghi</label><br> <p>SELECT要素<br> <select name="d5"><option value="">-- <option value="1">abc <option value="2">def</select> <p>TEXTAREA要素<br> <textarea name="d6" cols="80" rows="5"> 改行すると、その行の最初のインデントが継続されます 改行せずに移動する場合は[Ctrl]+[Enter]、設定で逆の動作をします 改行すると、その行の最初のインデントが継続されます </textarea> <p>DATALIST要素にSUMMARYがある場合<br> <details> <summary>Summary OPEN1</summary> <ol><li>abc <li>def <li>ghi</ol> </details> <details open> <summary>Summary OPEN2</summary> <ol><li>abc <li>def <li>ghi</ol> </details> <p>INPUT[type=button] / BUTTON 要素の場合<br> <input type="button" value="フォームの移動を無効にする" onclick="F.disabled=true"> <input type="button" value="フォームの移動を有効にする" onclick="F.disabled=false"> </p> </form> <script> /* [Enter] 要素を次に移動する [Shift]+[Enter] 前に移動する [Ctrl]+[Enter]: 作用する */ //エンターキーで項目を移動する // // input 要素が1つの場合、機能しない。 // http://www.w3.org/TR/html5/constraints.html#implicit-submission class ExEnter { #root = null;//エンターキーで巡る親要素 #walker = null;//フォーカス要素の移動は treeWalker を利用する #imeFlag = null;//IME日本語入力中で未変換文字があり Enterが押されたことを感知するにはkeypress イベントがが実行されないことを利用する //________________________________________ constructor (root = document.documentElement, option = { }, disabled = false) { this.root = root;//setter 側で初期化処理を行う this.option = Object.assign ({ }, this.constructor.defaultOption, option); this.disabled = disabled; //true:機能停止 } // rootが指定されたら初期化する set root (root) { let handler = this.constructor.#handler; //まずイベントを取り外す if (this.#root) Object.keys (handler).forEach (eventType=> this.#root.removeEventListener (eventType, this, false) ); //イベントを登録する Object.keys (handler).forEach (eType=> root.addEventListener (eType, this, false) ); this.#root = root; this.#walker = root.ownerDocument.createTreeWalker ( root, NodeFilter.SHOW_ELEMENT, //要素ノードのみ this.constructor.#nodeFilter.bind (this) ); } get root () { return this.#root; } //ルートの中の対象となる最初の要素を返す get firstElement () { this.#walker.currentNode = this.root; return this.#walker.firstChild (); } //ルートの中の対象となる最後の要素を返す get lastElement () { this.#walker.currentNode = this.root; return this.#walker.lastChild (); } //ルートの中の対象となる要素のフォーカスを移動する direction= true:戻る /false:次へ move (direction = false, target = this.root.ownerDocument.activeElement) { if (this.disabled) return; if (! this.root.contains (target)) throw new Error ('ルート要素に含まれていない要素が指定されました'); const walker = this.#walker, isLoop = this.option.loop; walker.currentNode = target; let e = (direction) ? walker.previousNode () || (isLoop ? this.lastElement: null) : walker.nextNode () || (isLoop ? this.firstElement: null); if (e) e.focus (); } //対象となる要素にそこでの挙動を委ねる。戻り値で項目移動の判別 entrust (e, sw, prev) { //sw == ctrl, prev == shift if (this.disabled) return; let stay = false, tag = e.tagName; //stay == true 留まることを意味する if ('TEXTAREA' === tag && !prev) { //shiftキーが押されていない事が条件 if (sw ^ this.option.textarea) { //改行が行われる場合は留まる this.constructor.#insertCRLF (e, this.option.autoIndent); stay = true; } } else if (sw) { switch (tag) { case 'SUMMARY' : let parent = e.parentNode; parent.hasAttribute ('open') ? parent.removeAttribute ('open') : parent.setAttribute ('open', ''); stay = true; break; case 'A' : this.constructor.#fireEvent (e); stay = true; break; case 'INPUT' : switch (e.type) { case 'radio': if (e.checked = !e.checked) {//radio の checkedは選択肢が1つなので群の最後に移動 let radios = this.root.querySelectorAll (`input[type="radio"][name="${e.name}"]`); this.move (false, radios[radios.length-1]); } break; case 'checkbox' : e.checked = !e.checked; stay = true; break; case 'file' : case 'color' : case 'button' : case 'submit' : case 'reset' : this.constructor.#fireEvent (e); stay = true; break; } break; } } //値が有効? / summary & anchor has not checkValidity if (! stay && this.option.invalidStop && e.checkValidity) // stay = ! e.checkValidity (); return stay; } //イベントハンドラ(各typeによって分岐) handleEvent (event) { let handler = this.constructor.#handler[event.type]; if (handler) handler.call (this, event); } //________________________________________ //記載されたイベントは定義時に登録される {keyDown, keyup} static #handler = { 'keydown': function (event) { if ('Enter' === event.code) { let { target: { nodeName, type } } = event; if (/^(submit|reset|button|textarea|file|color)$/.test (type) || /^(SUMMARY|A)$/.test (nodeName)) event.preventDefault (); this.#imeFlag = event.isComposing;//IME変換中にEnterキーで確定、keyupでスルーするため } else this.#imeFlag = false; }, 'keyup': function (event) { if ('Enter' === event.code) { //IMEの動作で未変換中の文字があると判断してスルー if (this.#imeFlag) return event.preventDefault (); let { shiftKey, ctrlKey, target } = event, tmp; if (! (tmp = this.entrust (target, ctrlKey, shiftKey))) this.move (shiftKey, target); this.#imeFlag = false; } } }; //TreeWalker用のフィルター関数(要.bind(this)) static #nodeFilter (node) { const accept = NodeFilter.FILTER_ACCEPT, skip = NodeFilter.FILTER_SKIP, {option} = this; switch (node.nodeName) { case 'INPUT' : case 'TEXTAREA' : case 'SELECT' : case 'BUTTON' : if (node.disabled || node.readOnly) break; if (option.skip_tabIndex && ('-1' === node.getAttribute ('tabIndex'))) break; if (this.constructor.#isHide (node)) break; return accept; case 'A' : //href = '#...' で始まるもののみ対象とする if (option.anchor) return accept; break; case 'SUMMARY' : if (! option.details) break; return accept; } return skip; } //祖先の要素が隠された状態にあるか? static #isHide (node) { const chks = ['display', 'visibility', 'opacity', 'height', 'width']; for (let e = node; e !== document.body; e = e.parentNode) { let cs = getComputedStyle (e, null), [d, v, o, h, w] = chks.map (p=> cs.getPropertyValue (p));//それぞれchksの値 if ( 'none' === d || 'hidden' === v || 0 == parseFloat (o) || ! ('auto' === h || 0 < parseInt (h, 10)) || ! ('auto' === w || 0 < parseInt (w, 10)) ) return true; } return false; } //対象の要素にクリックイベントを発火させる static #fireEvent (target) { if (! target) throw new Error ('引数が無効!!'); let event = target.ownerDocument.createEvent ('MouseEvents'); event.initEvent ('click', false, true); target.dispatchEvent (event); return event; } //textarea要素に改行を挿入する static #insertCRLF (t, autoIndent = false, start = t.selectionStart || 0) { let str = '\n', value = t.value, first = value.slice (0, start), last = value.slice (start).replace (/^([\t\u3000\u0020]+)/, ''); if (autoIndent) { let spc = /(?:\n|^)([\t\u3000\u0020]+).*$/.exec (first); if (spc) str += spc[1]; } t.value = first + str + last; t.selectionStart = t.selectionEnd = start + str.length; } //________________________________________ //初期設定オプション値 static defaultOption = { loop: true, //要素を巡回する skip_tabIndex: true, //tabIndex属性が "-1" の場合フォーカスを飛ばす anchor: true, //アンカータグを有効にする(但し href属性の値が "#"から始まる場合) details: true, //details要素を巡回対象とする invalidStop:true,//要素の値が不正な場合でも移動する textarea: true, //true: 改行[Enter], 移動[CTRL]+[Enter], false: 移動[Enter], 改行[CTRL]+[Enter] autoIndent: true, //textarea要素内でのオートインデントを行う }; //インスタンスの作成 static create (root = document.documentElement, option = { }, disabled = false) { let obj = new this (root, option, disabled); return obj; } } const enter = ExEnter.create (TEST);