Vue.js＋Vuetifyで無限スクロール可能なドロップダウンを作成してみる

動的に項目を増やせるドロップダウンが欲しかったのですが、Vuetifyに見当たらなかったので作ってみることにしました。

(2021/12/23追記)
実際に動かせるサンプルを公開しました
https://teracy164.github.io/infinite-scroll-drop-down/
コードは以下にあります。
https://github.com/teracy164/infinite-scroll-drop-down


# バージョン

```
$ vue --version
@vue/cli 4.5.11
```

# 環境構築

```shell
# Vueプロジェクトを作成
vue create infinite-scroll-drop-down
? Please pick a preset: Default ([Vue 2] babel, eslint)

cd infinite-scroll-drop-down

# Vuetifyをインストール
vue add vuetify
? Choose a preset: Default (recommended)
```

※執筆時点でVuetifyがVue3に対応していないため、Vue2で構築します


# まずは手動で項目読み込みできるセレクトボックスを作る

※`v-select`の基本的な使い方はリファレンスを参照してください
https://vuetifyjs.com/ja/components/selects/

- append-itemでリスト末尾に読み込みボタンを配置します
- 読み込みボタン押下時は親コンポーネントに`next`イベントをemitします

```src/components/parts/InfiniteScrollDropDown.vue
<template>
    <v-select
        :items="items"
        :label="label"
        item-text="label"
        item-value="value">
        <template v-if="!loadCompleted" v-slot:append-item>
            <v-divider class="mb-2"></v-divider>
            <v-list-item v-if="!loading" @click="onclickload">
                load
            </v-list-item>
            <v-list-item v-else>
                loading...
            </v-list-item>
        </template>
    </v-select>
</template>

<script>
  export default {
    props: {
        items: {
            type: Array,
            default: () => [],
        },
        label: {
            type: String,
            default: '',
        },
        loadCompleted: {
            type: Boolean,
            default: false,
        },
        loading: {
            type: Boolean,
            default: false,
        },
    },

    methods: {
        onclickload() {
            this.$emit('next')
        }
    }
  }
</script>
```

- 親のコンポーネントで作成したコンポーネントを配置します
    - 必要なデータを渡してあげます

```src/components/HelloWorld.vue
<template>
  <v-container>
    <v-row class="text-center">
      <v-col cols="12">
        <InfiniteScrollDropDown
          :items="items"
          :loadCompleted="data.length === loadedItemNum"
          :loading="loading"
          @next="onnext()" />
      </v-col>
    </v-row>
  </v-container>
</template>

<script>
import InfiniteScrollDropDown from './parts/InfiniteScrollDropDown'

  export default {
    name: 'HelloWorld',

    components: {
      InfiniteScrollDropDown,
    },

    mounted() {
      this.onnext()
    },

    data: () => {
      return {
        // MAX５０個のリスト
        data: new Array(50).fill().map((v, i) => ({value: i + 1, label: `item${i + 1}`})),
        items: [],
        loadedItemNum: 0,
        loading: false,
      }
    },

    methods: {
      onnext() {
        this.loading = true
        // 非同期での動作確認のため少し待ってから追加する
        setTimeout(() => {
          const step = 10;
          this.items.push(...this.data.slice(this.loadedItemNum, this.loadedItemNum + step));
          this.loadedItemNum += step;
          this.loading = false;
        }, 500);
      }
    }
  }
</script>
```

この段階で↓のようにリスト末尾のloadを押下すると次の１０件を読めるようになります

![スクリーンショット 2021-02-16 18.28.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/325108/fe010046-d3bf-ec86-2aaa-75ae2d2f863c.png)



# 無限スクロール対応

リストの中身を任意のタイミングで追加することができるようになったので、あとはリスト末尾までスクロールしたときに`next`イベントをemitしてやれば無限スクロールの完成です。


実際に無限スクロールに対応させたものが↓になります

```src/components/parts/InfiniteScrollDropDown.vue
<template>
    <v-select
        :items="items"
        :label="label"
        item-text="label"
        item-value="value"
        :menu-props="{'content-class': listClass}"
        @click="onclick">
        <template v-if="!loadCompleted" v-slot:append-item>
            <v-divider class="mb-2"></v-divider>
            <v-list-item v-if="!loading" @click="onclickload">
                load
            </v-list-item>
            <v-list-item v-else>
                loading...
            </v-list-item>
        </template>
    </v-select>
</template>

<script>
  export default {
    props: {
        items: {
            type: Array,
            default: () => [],
        },
        label: {
            type: String,
            default: '',
        },
        loadCompleted: {
            type: Boolean,
            default: false,
        },
        loading: {
            type: Boolean,
            default: false,
        },
    },

    data() {
        return {
            listClass: 'my-select-list',
        };
    },

    methods: {
        onclick() {
            let cnt = 0;
            const getList = () => {
                if (++cnt > 20) return;

                const elements = document.getElementsByClassName(this.listClass);
                if (elements.length) {
                    const menu = elements[0];
                    menu.addEventListener('scroll', () => {
                        if (menu.scrollHeight - (menu.clientHeight + menu.scrollTop) < 5) {
                            this.scrollend()
                        }
                    });
                } else {
                    setTimeout(() => getList(), 100);
                }
            };

            getList();
        },
        scrollend() {
            if (!this.loadCompleted) {
                this.$emit('next');
            }
        },
        onclickload() {
            this.$emit('next');
        }
    }
  }
</script>
```

### ポイント

- `:menu-props="{'content-class': listClass}"`で展開されるリストにclassを設定しておきます
    - ドロップダウンのリストはv-menuで実現されているため、探せるように明示的に名前をつけています
    - なお、同一画面内に複数のドロップダウンを配置したい場合はユニークになるように工夫が必要です
- クリック時に上記のクラス名でv-menuを検索します
    - クリック指定から表示されるまでに時間がかかるため、表示されるのを待ちます
    - リストは`this.$el`下にはなく、bodyのほぼ直下に作成されるため、`document.get〜`で探します
- scrollイベントを監視し、末尾までスクロールされたかチェックします
- 末尾までスクロールされた際にまだ読み込み可能なデータがあればnextイベントを親コンポーネントにemitします



# まとめ

これで無限スクロール対応のドロップダウンができました。
思ったより簡単に実現できたので、いろんなところで活用できそうです＾＾