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);