はじめに
Webアプリ構築で入力フォームのUI構築の際、テキスト入力の自動補完をやってくれると便利!って思うことが時々あります。自動補完をやるプラグインやライブラリはネットを探せばたくさん見つかりますが、何れも帯に短したすきに長しみたいな印象があります。特にアプリが扱うデータの「とある項目」に限定して入力自動補完をやらせたいといったシチュエーションで小回りが利かないと感じるとがよくありす。今回は自動補完(Autocomplete)を実現する自前のUIコンポーネントを『自動補完付きテキスト入力コンポーネント』と称して作ってみたいと思います。
出来上がりはこんな感じ!
いつもと同じく、まずは出来上がりのイメージから。こんな感じです。 超シンプル!です。
(テーマに特化してわかりやすくするのに至ってシンプルにしてみました)
コードはここです。
GitHub - Shikataramuno/autocomplet-sample: 自動補完UIコンポーネントのサンプル
この辺りを参考にさせていただいております。
Create Your Own Autocomplete Using Vue.js 2
Building an Autocomplete Component with Vue.js ← Alligator.io
自動補完の構造と振る舞い
自動補完はテキスト入力に連動して、対象データの中から入力テキストを含むものを選択候補として抽出しリスト表示します。
構造
『テキスト入力のフォーム』と『候補表示のリスト』でUIを構築します。
これらの要素を定義する「自動補完付き入力コンポーネント」とその「親コンポーネント」でコンポーネントを構成します。振る舞い
『テキスト入力のフォーム』でキー操作のイベントを検知し、そのハンドラで対象データをフィルタ、『候補表示のリスト』を更新する仕組みです。
コンポーネントの独立性や再利用性を考慮し、今回は選択対象のデータは親コンポーネントからプロパティとして渡し、『テキスト入力のフォーム』のエンター操作または、『候補表示のリスト』の選択操作でイベントを発火させて確定した入力データを親コンポーネントに返す構造とします。UMLのコミュニケーション図っぽく描くとこんな感じでしょうか。
では実装してみましょう
プロジェクトの作成
何はともあれ、プロジェクトの作成からです。
プロジェクト名を 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.json
のrules
タグに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 {} // 領域外クリックのイベントハンドラ削除 }
- 6行目で親コンポーネントから渡される選択候補のリストを定義しています。このサンプルでは
string
の配列として定義しています。 - 14行目で選択候補をフィルタした結果を格納するメンバ変数を定義しています。選択候補と同じくstringの配列としています。
- 17,18行目で入力フォームの入力テキストで選択候補をフィルタし、候補リストを更新する処理を定義しています。
- 19,20行目では、選択候補のリストを
↓
、↑
キーで選択するイベント処理を定義しています。 - 22~25行目では入力フォームのEnterもしくはリストの選択(クリック)に伴う確定イベントの処理を定義しています。
- 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.results
がli
タグでリスト表示される配列になります。すなわち、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に変更すれば出来上がりです。
自前の『自動補完付き入力コンポーネント』を使う機会がどれくらいあるかは?ですが、レシピに加えておくとイザってときに機転が利いて役立つかもしれませんね。
謝辞
最後まで読んでいただいてありがとうございます。
説明が細かくなってしまい、却ってわかりづらくなったかもしれませんが、ご容赦お願いいたします。
また、何かコメントあればお願いいたします。