しかたらむーの てっくぶろ

組み込みソフトの古参エンジニアがモダンなフロントエンドエンジニアに転生 その進化の記録です

Vue.js + Typescript で 入力自動補完(Autocomplete)コンポーネントを作ってみる

はじめに

Webアプリ構築で入力フォームのUI構築の際、テキスト入力の自動補完をやってくれると便利!って思うことが時々あります。自動補完をやるプラグインやライブラリはネットを探せばたくさん見つかりますが、何れも帯に短したすきに長しみたいな印象があります。特にアプリが扱うデータの「とある項目」に限定して入力自動補完をやらせたいといったシチュエーションで小回りが利かないと感じるとがよくありす。今回は自動補完(Autocomplete)を実現する自前のUIコンポーネントを『自動補完付きテキスト入力コンポーネント』と称して作ってみたいと思います。

出来上がりはこんな感じ!

いつもと同じく、まずは出来上がりのイメージから。こんな感じです。 超シンプル!です。
(テーマに特化してわかりやすくするのに至ってシンプルにしてみました)

f:id:Shikataramuno:20190219125417p:plain

コードはここです。
GitHub - Shikataramuno/autocomplet-sample: 自動補完UIコンポーネントのサンプル

この辺りを参考にさせていただいております。
Create Your Own Autocomplete Using Vue.js 2
Building an Autocomplete Component with Vue.js ← Alligator.io

自動補完の構造と振る舞い

自動補完はテキスト入力に連動して、対象データの中から入力テキストを含むものを選択候補として抽出しリスト表示します。

  • 構造
    『テキスト入力のフォーム』と『候補表示のリスト』でUIを構築します。
    これらの要素を定義する「自動補完付き入力コンポーネント」とその「親コンポーネント」でコンポーネントを構成します。

  • 振る舞い
    『テキスト入力のフォーム』でキー操作のイベントを検知し、そのハンドラで対象データをフィルタ、『候補表示のリスト』を更新する仕組みです。
    コンポーネント独立性再利用性を考慮し、今回は選択対象のデータは親コンポーネントからプロパティとして渡し、『テキスト入力のフォーム』のエンター操作または、『候補表示のリスト』の選択操作でイベントを発火させて確定した入力データを親コンポーネントに返す構造とします。UMLのコミュニケーション図っぽく描くとこんな感じでしょうか。

f:id:Shikataramuno:20190219111415p:plain

では実装してみましょう

プロジェクトの作成

何はともあれ、プロジェクトの作成からです。
プロジェクト名を autocomplete-sampleとしてvue create コマンドでプロジェクトを作成します。(プロジェクト名は任意で構いません) 作成時のオプション指定は以下のとおりです。

vue create autocomplete-sample↵
Vue CLI v3.1.1
? Please pick a preset: Manually select features
? Check the features needed for your project:
>(*) Babel
 (*) TypeScript
 ( ) Progressive Web App (PWA) Support
 ( ) Router
 ( ) Vuex
 ( ) CSS Pre-processors
 (*) Linter / Formatter
 ( ) Unit Testing
 ( ) E2E Testing
↵
? Please pick a preset: Manually select features
? Check the features needed for your project: Babel, TS, Linter
? Use class-style component syntax? Yes
? Use Babel alongside TypeScript for auto-detected polyfills? Yes
? Pick a linter / formatter config: TSLint
? Pick additional lint features: Lint on save
? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? In package.json
? Save this as a preset for future projects? No 

プロジェクトができたらsrc/component以下に「親コンポーネント」と「自動補完付き入力コンポーネント」を作成します。 フォルダ構成はこんな感じになります。

autocomplet-sample
│  babel.config.js
│  package.json
│  tsconfig.json
│  tslint.json
├─node_modules
├─public
└─src
    │  App.vue
    │  main.ts
    │  shims-tsx.d.ts
    │  shims-vue.d.ts
    │  tree.txt
    ├─assets
    │      logo.png
    └─components
            Autocomplete.vue  <--これが「自動補完付き入力コンポーネント」
            ParentView.vue     <-- これが「親コンポーネント」

ここでコード実装上、可視性(publicやらprivateやら)の指定が省略できるようにtslint.jsonrulesタグにmember-access属性を、 デバッグ文を出力できるようにno-console属性を以下の様に指定しておきます。

  "rules": {
    "quotemark": [true, "single"],
    "indent": [true, "spaces", 2],
    "interface-name": false,
    "ordered-imports": false,
    "object-literal-sort-keys": false,
    "no-consecutive-blank-lines": false,
    "no-console": false,    <-- ここ
    "member-access": [true, "no-public"]    <-- ここ
  }

自動補完付き入力コンポーネント

自動補完付き入力コンポーネントAutocomplete.vueになります。

HTML構造

構造からみていきましょう。

<template>
  <div class="autocomplete">
    <input class="autocomplete-input" type="text"
      v-model="search"
      @input="onChange()"
      @keydown.down="onArrowDown"
      @keydown.up="onArrowUp"
      @keydown.enter="onEnter"/>
    <ul ref="list" class="autocomplete-results" v-show="isOpen">
      <li class="autocomplete-result" v-for="(result, i) in results" :key="i"
        @click="setResult(result)"
        :class="{ 'is-active': i === arrowCounter }">
        {{ result }}
      </li>
    </ul>
  </div>
</template>

ユーザーからのテキスト入力は 3行目のinputタグの部分が該当します。keydownイベントでキー、キー、Enterキーの押下を検知し、それぞれイベントハンドラを呼び出す様に定義しています。

はリストに表示されている候補をキー入力でカーソル移動させて選択するためのイベントになります。一方、Enterはフォームに入力されたテキストを確定するため、もしくは選択されたリストの候補を確定するための操作になります。

9行目~15行目のulタグで囲まれたブロックが候補表示のリストになります。この部分はisOpenでテキスト入力に連動して表示/非表示を切り替えています。

それぞれの選択候補は10行目~14行目でliタグを使ってv-forディレクティブでresultsの配列長分繰り返し定義しています。

11行目で要素にクリックイベントをバインドし、12行目でによるリスト上のカーソル位置(arrowCounter)と配列番号(i)が同値の場合に、要素が選択中であるとしてis-active要素をclassにバインドしstyleで背景色を切り替えています。

振る舞い

次に振る舞いの方です。 コンポーネントのインターフェース定義は次のとおりです。

import { Component, Prop, Emit, Watch, Vue } from 'vue-property-decorator';

@Component
export default class AutoComplete extends Vue {
  @Prop({default: ''}) option!: string; // 確定内容
  @Prop({default: []}) options!: string[]; // 選択候補のリスト
  @Prop({default: true}) onChangeEvent!: boolean;
  $refs!: {
    'list': HTMLElement;
  };

  search: string = '';
  isOpen: boolean = false;
  results: string[] = [];
  arrowCounter: number = -1;

  onChange(): string {}  // 入力フォームの入力イベント
  filterResults(): void {} // 選択候補のフィルタ
  onArrowDown(): void {} // ↓ キーイベント
  onArrowUp(): void {} // ↑ キーイベント

  @Emit('input')
  onEnter(): string {} // 入力フォームの確定イベントと親へのEmit
  @Emit('input')
  setResult(result: string): string {} // 候補リストの選択イベントと親へのEmit

  @Watch('option')
  onOptionChanged(newVal: string, oldVal: string): void {} // 確定内容でフォーム表示

  handleClickOutside(evt: any): void {} // 候補リストの領域外クリックでリスト非表示
  mounted(): void {} // 領域外クリックのイベントハンドラ登録
  destroyed(): void {} // 領域外クリックのイベントハンドラ削除
}
  1. 6行目で親コンポーネントから渡される選択候補のリストを定義しています。このサンプルではstringの配列として定義しています。
  2. 14行目で選択候補をフィルタした結果を格納するメンバ変数を定義しています。選択候補と同じくstringの配列としています。
  3. 17,18行目で入力フォームの入力テキストで選択候補をフィルタし、候補リストを更新する処理を定義しています。
  4. 19,20行目では、選択候補のリストをキーで選択するイベント処理を定義しています。
  5. 22~25行目では入力フォームのEnterもしくはリストの選択(クリック)に伴う確定イベントの処理を定義しています。
  6. 30~32行目では、選択候補のリストが表示されている状態で、リスト外の領域をクリックした際にリストを閉じる振る舞いを定義しています。
    mounted, destroyedのLifeCycle hook method で 領域外のクリックイベントハンドラを登録、削除しています。

コード実装のポイント

選択候補フィルタと候補リスト更新

振る舞いの説明 3項にあたる部分です。

  onChange(): string {
    this.filterResults();
    this.isOpen = true;
    return this.search;
  }
  filterResults(): void {
    this.results = this.options.filter((option) => {
      return option.indexOf(this.search) > -1;
    });
    this.arrowCounter = -1;
  }

文字入力とともにonChangeハンドラが呼び出されます。その中で filterResultを呼び出し、isOpenを更新しリスト表示を有効化しています。

filterResultではthis.searchに格納されている入力文字列で選択候補の配列をフィルタし、結果をthis.resultsに格納し、カーソル位置を初期化しています。

このthis.resultsliタグでリスト表示される配列になります。すなわち、1文字入力するごとにフィルタが掛けられてリストが更新されます。上図のユーザーのテキスト入力で候補表示される部分になります。

Enter操作による確定処理

振る舞いの5項にあたる部分です。

@Emit('input')
onEnter(): string {
  if (this.arrowCounter >= 0) {
    const ret = this.results[this.arrowCounter];
    this.isOpen = false;
    this.arrowCounter = -1;
    return ret;
  } else {
    if (this.search !== '') {
      this.isOpen = false;
      this.arrowCounter = -1;
    }
    return this.search;
  }
}

3行目でthis.arrowCounterで条件分岐しています。これは、キーでリスト中の候補が選択されていればそれを優先し、選択されていなければフォームの入力値を採用するという仕様にしたためです。

キーに連動したスクロール

振る舞いの説明 4項にあたる部分です。

  onArrowDown(): void {
    if (this.arrowCounter < this.results.length - 1) {
      this.arrowCounter = this.arrowCounter + 1;
      const scrollTop = (this.arrowCounter - 3) * 32 + 8;
      if (this.$refs.list.scrollTop < scrollTop) {
        this.$refs.list.scrollTop = scrollTop;
      }
    }
  }
  onArrowUp(): void {
    if (this.arrowCounter > 0) {
      this.arrowCounter = this.arrowCounter - 1;
      if (this.$refs.list.scrollTop > this.arrowCounter * 32) {
        this.$refs.list.scrollTop = this.arrowCounter * 32;
      }
    }
  }

キーに連動してthis.arrowCounterの値をインクリメント、デクリメントさせて候補選択させます。
その際、画面上に選択した項目が必ず表示されるよう選択した位置に応じてリストをスクロールさせています。具体的には4~7行名、13~15行目でulタグのscrollTopの値を更新してキー操作に連動したスクロールを実現しています。オフセットさせる値は全体のレイアウトを見ながら調整します。

コンポーネント

コンポーネントParentView.vueになります。

HTML構造

Autocomplete.vueと同じく構造からみていきましょう。

<template>
  <div class="parent-view">
    <label>自動補間サンプル</label>
    <autocomplete
      :option="option"
      :options="options"
      :onChangeEvent=false
      @input="optionSelected">
    </autocomplete>
  </div>
</template>

至ってシンプルです。autocompleteの構造を埋め込んで、5~7行目プロパティを渡し、8行目でイベントハンドラを登録しているだけです。

振る舞い

次に振る舞いの方です。 コンポーネントのインターフェース定義は次のとおりです。

import { Component, Prop, Vue } from 'vue-property-decorator';
import Autocomplete from './Autocomplete.vue';

@Component({
  components: {
    Autocomplete,
  },
})
export default class ParentView extends Vue {
  name: string = 'ParentView';
  options: string[] = [];
  option: string = '';

  optionSelected(option: string): void {
    this.option = option;
  }
  created(): void {
    console.log('ParentView creates');
    this.options = [
      'aaaa',
   ~中略~
      'あさって',
    ];
  }

14行目で Autocomplete.vueからのinputイベントハンドラを定義し、その中でPropで渡す確定データのメンバ変数に格納しています。

19行目で選択候補リストの初期値を設定しています。サンプルの親コンポーネントはテストドライバ的な位置づけでもあるのでいたってシンプルな実装で済みます。

出来上がり!

後はApp.vueでHelloWorldをParentViewに変更すれば出来上がりです。
自前の『自動補完付き入力コンポーネント』を使う機会がどれくらいあるかは?ですが、レシピに加えておくとイザってときに機転が利いて役立つかもしれませんね。

謝辞

最後まで読んでいただいてありがとうございます。
説明が細かくなってしまい、却ってわかりづらくなったかもしれませんが、ご容赦お願いいたします。 また、何かコメントあればお願いいたします。