Vue.js で入力フォームを↑↓キーやタブ・シフトタブでフォーカス移動する


Vue.js に限った話ではないですが、複数ある入力フォームを `↑` や `↓` `Tab` `Shift + Tab` キーでフォーカスを移動させるのにちょっと悩んだので僕なりのやり方をまとめておきます。

↓こういう動きをさせたいという話です。

![focus.gif](https://qiita-image-store.s3.amazonaws.com/0/2152/722bdb69-617c-b9cc-7291-43708ae3f137.gif "focus.gif")


## `tabindex` は使わないのか？

`tabindex` を使えば `Tab` キー押下時の入力フォーカスの順序を制御できます。しかしこれはグローバル要素であり、HTML 全体で指定数値の整合性を取る必要があり非常に面倒くさいです。また、あくまで `Tab` キー押下だけの影響下であり、`↑` などの別のキーには影響を与えません。

そこで、普通にキーイベントを拾ってフォーカスを移動させるようにします。

## Vue.js のキーバインドイベント

Vue.js のキーイベントは簡単に指定できます。いかが完成形のイベント指定です。

```html
<input type="text"
       class="input-items"
       @keydown.prevent.tab.exact="moveNext"
       @keydown.prevent.shift.tab="movePrev"
       @keydown.prevent.down="moveNext"
       @keydown.prevent.up="movePrev">
```

まず `Tab` キーは `@keyup` ではなく `@keydown` を利用し `prevent` 指定が必要です。指定しないと標準の `Tab` キー押下時の挙動に遷移してしまいます。また、 `shift.tab` でシステム修飾子キーを使って `Shift + Tab` のキーバインドイベントを設定していますが、この時 `tab` イベントも同時に発火してしまいます。これは仕様です。

そこで `exact` 修飾子を利用します。これはシステム修飾子の正確な記述ができるものです。以下は公式からのサンプル引用です。

```html
<!-- これは Ctrl に加えて Alt や Shift キーが押されていても発行されます -->
<button @click.ctrl="onClick">A</button>

<!-- これは Ctrl キーが押され、他のキーが押されてないときだけ発行されます -->
<button @click.ctrl.exact="onCtrlClick">A</button>

<!-- これは システム修飾子が押されてないときだけ発行されます -->
<button @click.exact="onClick">A</button>
```

- https://jp.vuejs.org/v2/guide/events.html#exact-%E4%BF%AE%E9%A3%BE%E5%AD%90

これを利用することで `Tab` キーのみが押下されたときにだけイベントを発火できます。

## 前後の入力フォームエレメントを探す

純粋に前後のエレメントであれば `Node.previousSibling` や `Node.nextSibling` が使えます。

```html
<input type="text" @keydown.prevent.tab.exact="moveNext">
<input type="text">
```

```javascript
methods: {
  moveNext (event) {
    event.target.nextSibling.focus()
  }
}
```

しかしこれは他の要素が入ってくると駄目になります。

### 同名 class で探す

そこで対象の入力フォームに同じ class を付けて検索するという地道な方法を採用します。他にもっと良いやり方があれば教えてください。要素の数が増えれば増えるほどパフォーマンスが劣化するという悲しさはあります。

```html
<input type="text"
       class="input-items"
       @keydown.prevent.tab.exact="moveNext">
```

```javascript
methods: {
  moveNext (event) {
    const elements = document.getElementsByClassName('input-items')
    const index = [].findIndex.call(elements, e => e === event.target)
    elements[index + 1].focus()
  }
}
```

指定 class のエレメントを集めて自分自身の順序を取得して、その前後のインデックスでエレメントを取得するというやり方です。ここまでできればあとは実装するだけです。

## サンプルコード

```vue
<template>
  <div>
    <button @click="num++">add input field</button>
    <p>input field</p>
    <ul>
      <li v-for="(key, index) in lists" :key="`input${index}`">
        <input type="text"
               class="input-items"
               @keydown.prevent.tab.exact="moveNext"
               @keydown.prevent.shift.tab="movePrev"
               @keydown.prevent.down="moveNext"
               @keydown.prevent.up="movePrev">
        <button>button</button>
      </li>
    </ul>
    <p>contenteditable</p>
    <ul>
      <li v-for="(key, index) in lists" :key="`editable${index}`">
        <span contenteditable="true"
              class="input-items"
              @keydown.prevent.tab.exact="moveNext"
              @keydown.prevent.shift.tab="movePrev"
              @keydown.prevent.down="moveNext"
              @keydown.prevent.up="movePrev"></span>
        <button>button</button>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'HelloWorld',
  data () {
    return {
      num: 3
    }
  },
  computed: {
    lists () {
      return Array.from(Array(this.num).keys())
    },
    elements () {
      return document.getElementsByClassName('input-items')
    }
  },
  methods: {
    findIndex (target) {
      return [].findIndex.call(this.elements, e => e === target)
    },
    moveFocus (index) {
      if (this.elements[index]) {
        this.elements[index].focus()
      }
    },
    moveNext (event) {
      const index = this.findIndex(event.target)
      this.moveFocus(index + 1)
    },
    movePrev (event) {
      const index = this.findIndex(event.target)
      this.moveFocus(index - 1)
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
li {
  margin: 5px 0;
  list-style: none;
}
span, input {
  font-size: 12px;
  min-width: 150px;
  padding: 5px;
  display: inline-block;
  border: 1px solid #ccc;
  text-align: left;
  color: #000;
}
</style>
```

動作デモはこちら

- https://codesandbox.io/s/4w4l665m10