カーソルキーでプルダウンを選択する

# 始めに
よくセレクトボックスで操作感をよくするためにキーボードでも選択ができるようにしているサイトがありますが、具体的にどうやっているかをまとめました。

# 実装方法
端的にいえば、focus処理をJS側で泥臭く設定しています。もうちょっとなんとかならないのかなと思いましたが、これがスタンダードなやり方な気がしました（他にやり方があれば教えて欲しいです）。
具体的には`tabindex="0"`を設定してfocusできる要素にして、後はキーイベントを拾ってそれに応じて前や後の要素をfocusさせます。
実装イメージは以下の通りです。

```vue:実装イメージ
<template lang="pug">
.select-list
  template(v-for="option in $props.options")
    //- tabindexをセットして、keydownイベントでキー操作をハンドリングする
    .select-list__item(
      :key="option.id"
      ref="elItems"
      tabindex="0"
      @click="onSelect(option.id)"
      @keydown="onItemKeyDown"
    )
      | {{ option.text }}
</template>

<script>
/** キーボードの入力コード */
const KEY_CODES = {
  ENTER: 13,
  UP: 38,
  DOWN: 40,
};

Vue.component('SelectBox', {
  data() {
    return {
      focusIndex: 0,
    };
  },
  methods: {
    focusItem(index) {
      this.$data.focusIndex = _.clamp(index, 0, this.$refs.elItems.length - 1);
      const elItem = this.$refs.elItems[this.$data.focusIndex];
      elItem.focus();
    },
    onItemKeyDown(event) {
      event.preventDefault();
      event.stopPropagation();

      // Enterキー: クリックイベントを発火させて、クリックと同じ挙動にする
      if (event.keyCode === KEY_CODES.ENTER) {
        const elItem = this.$refs.elItems[this.$data.focusIndex];
        elItem.click();
      }

      // ↑キー: 一つ上の要素をフォーカスさせる
      if (event.keyCode === KEY_CODES.UP) {
        this.focusItem(this.$data.focusIndex - 1);
        return;
      }

      // ↓キー: 一つ下の要素をフォーカスさせる
      if (event.keyCode === KEY_CODES.DOWN) {
        this.focusItem(this.$data.focusIndex + 1);
        return;
      }
    },
  },
});
</script>
```

実際に実装したサンプルコードは以下になります。

<p class="codepen" data-height="400" data-theme-id="dark" data-default-tab="js,result" data-user="wintyo" data-slug-hash="GRpzQLy" style="height: 400px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="カーソルキーでプルダウンを選択する">
  <span>See the Pen <a href="https://codepen.io/wintyo/pen/GRpzQLy">
  カーソルキーでプルダウンを選択する</a> by wintyo (<a href="https://codepen.io/wintyo">@wintyo</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

# 検索+バーチャルスクロールの場合
検索ボックスがあってそこから↓キーで項目にfocusする方法も同じようにJSでハンドリングすればいいですが、項目数が多くてバーチャルスクロールで実装した場合はかなりの鬼門でした。。。
ここでは[vue-virtual-scroller](https://github.com/Akryum/vue-virtual-scroller)のRecycleScrollerを使った際の実装方法について書いていきます。

## focus対象の要素はquerySelectorで探す
何番目とかで見つけられないので、クラスを付与してそのクラスを探すようにしました。ここで注意なのは再利用しているということで使われていない要素に昔の情報が残っていることがあるので、キチンとactiveなものにだけクラスをつけるようにします。

```vue:実装イメージ
<template lang="pug">
RecycleScroller(
  ref="scroller"
  :items="_filteredOptions"
  :itemSize="40"
  keyField="id"
  v-slot="{ item, active }"
)
  .select-item(
    tabindex="0"
    :class="{ [`js-active-item-${item.id}`]: active }"
    @click="onSelect(item.id)"
    @keydown="onItemKeyDown"
  )
    | {{ item.text }}
</template>

<script>
Vue.component('SelectBox', {
  methods: {
    focusItem(index) {
      if (!this.$refs.scroller) {
        console.warn('scroller not found');
        return;
      }
      this.$data.focusIndex = _.clamp(index, 0, this._filteredOptions.length - 1);
      const option = this._filteredOptions[this.$data.focusIndex];
      // RecycleScrollerにあるエレメントから対象のDOMを探す
      const elItem = this.$refs.scroller.$el.querySelector(`.js-active-item-${option.id}`);
      if (!elItem) {
        console.warn('focus item lost!');
        return;
      }
      elItem.focus();
    },
  },
});
</script>
```

## 検索ボックスから項目に移動する際は上にスクロールしてからにする
バーチャルスクロールで一番上の要素が無い場合があるのでスクロールしてからフォーカスします。
`scrollToItem`はドキュメントには書かれてなかったのですが、Issueには書かれてました（結構危ないかも。。。）
https://github.com/Akryum/vue-virtual-scroller/issues/180

```js:スクロールしてからフォーカス
if (event.keyCode === KEY_CODES.DOWN) {
  // 上にスクロールしてからフォーカスする
  this.$refs.scroller.scrollToItem(0);
  window.setTimeout(() => {
    this.focusItem(0);
  }, 10);
}
```

## 要素がblurされるたびにfocusし直す
使いまわしていくので、中身が更新されてfocusが外れてしまう場合があります。これをどうしようか色々検討したのですが、最終的にはblurされたらfocusし直すやり方が一番focusをキープできました。ただしこれでも上手くfocusできない時がありました。。。そもそもスクロールしているとfocus対象の要素が存在しない時もありますし、これが限界のような気がしました。

### 検討メモ
却下された他の方法についても記載しておきます。

<b>RecycleScrollerのupdateイベントをみて更新する</b>
updateイベントは更新後だと思っていましたが、どうやら違うような気がしていて、updateイベント後にフォーカスし直しても外れてしまうことが多々ありました。setTimeoutを挟んで一定時間置いてからfocusするという方法もなくは無いですが、それだと不安定なので却下となりました。

## サンプルコード
以上の実装をしたサンプルは以下の通りです。

<p class="codepen" data-height="409" data-theme-id="dark" data-default-tab="js,result" data-user="wintyo" data-slug-hash="NWGoVoB" style="height: 409px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="カーソルキーでプルダウンする（バーチャルスクロール版）">
  <span>See the Pen <a href="https://codepen.io/wintyo/pen/NWGoVoB">
  カーソルキーでプルダウンする（バーチャルスクロール版）</a> by wintyo (<a href="https://codepen.io/wintyo">@wintyo</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

# 終わりに
以上がカーソルキーでプルダウンを操作する方法でした。バーチャルスクロールについてはキー操作させるのはかなり難しく、中途半端な感じになってしまいました。。。[vue-select](https://vue-select.org/)というライブラリはhover時もfocusする挙動になっていて、これにしていれば途中でfocusが外れてもそこまで気にならないのかなとか思ってきました。
ググり力が足りないだけかもしれませんが、意外とこういった記事が見当たらなかったので誰かのお役に立てれば幸いです。