Mithrilで自動補完つきテキストを作る(2)

前回はHTMLの標準の機能を使って補完機能を作りました。今回は別の仕組みを作り込みます。Mithril以外にも移植したい人とかいるだろうし、中身解説中心です。まあOSSっていっても、よっぽどすごいアルゴリズムとかでないとそのまま利用されることってそんなになくて、どちらかというとノウハウが動作可能な形で公開されている方が大事で、コード解説が充実している方が流用とかしやすいよね、という感じです。

* [サンプル](https://jsfiddle.net/d8rreonv/3/)

<img width="378" alt="スクリーンショット 2016-12-17 10.55.53.png" src="https://qiita-image-store.s3.amazonaws.com/0/6105/6a539914-4239-46bb-0551-009785816c31.png">

前回の仕組みは、1つの入力で1つのデータしか入れられませんが、タグ入力とかで複数のテキストを選択したり削除したくなることがありますよね。

いまでこそjQueryっていろいろ言われますが、このあたりのUI部品はjQueryでかなりいろいろ作られて、「HTML標準では実現しにくいことを1行で実現するUI部品集」としてはまだまだjQueryが強い印象あります。仮想DOMとかMVCとかはアプリケーションを組み立てる土台としては市民権を得てきているけど、末端の部品はまだまだ・・・多少Reactが追い上げてきているぐらいですよね。

jQueryの世界だとselect2を始めとしてこの手の入力フォームがいっぱいあるんですが、Mithrilにはないので、今回使えるように整備してみました。こちらに[一応コードはあったん](https://gist.github.com/StephanHoyer/ea6d1e9c5ea7a9225639)ですが、ドキュメントも何もなかったのでコードを見ながら使い方を探ったり、lodash依存しちゃっているのを外してみたり、CSSを用意したりしました。

元のコードはライセンスとか書かれていないですが、おそらく作者を見る限りMITでいいかと思います。僕のコードもMITとします。

FilterInput
-------------

FilterInputというのが今回整備したもので、コードはサンプルの中にあります。特徴としては次の通りです。

* 複数個のデータを入力できる。
* 現在の入力に対して、補完リストを出す
* 作られたものはリスト化される(スクショのようになる)。編集はできないが削除はできる。
* 候補にあるものしか入力できないようにするか、自由入力も許可するかどうかは外部から制御する。
* XHR依存とかはしない軽量実装

スクリーンショットの状態だと、下記のような仮想DOMが作られています。入力確定したものはリスト、入力候補もリスト(ただし何もなければ候補ウインドウは出ない)。テキスト入力だけはinputタグ、という構造になっています。

```js
m('.filterInput', [
    m('ul.currentFilters', [
        m('li.filter.word', ['Java', m('a', '❎')]),
        m('li.filter.word', ['C++', m('a', '❎')]),
    ]),
    m('form', [
        m('input', {placeholder: 'languages...'},
        m('ul.suggestions', {style: {display: 'block'}}, [
            m('li', 'JavaScript'),
        ])
    ]);
])
```

もちろんこれがタグ入力の絶対の解というわけではないです。Qiitaはテキスト入力の上に薄くDOMを重ねて、下の入力に合わせて同じテキストのコピーをspanタグでかこいつつ置いて枠を表現しているっぽいです。

つかいかた
-------------

コンポーネント自体は状態を持ちません。4つのコールバックと、1つのパラメータがあります。

```js
    view: (ctrl) => {
        return m(FilterInput, {
            placeholder: "languages...",
            getSuggestions: ctrl.wordSuggestions.bind(ctrl),
            getCurrentFilters: ctrl.wordFilters.bind(ctrl),
            onAddFilter: ctrl.addWord.bind(ctrl),
            onRemoveFilter: ctrl.removeWord.bind(ctrl)
        });
    }
```

なお、候補、選択されているデータ、追加、削除で使われるデータはすべて```{type: '種類', label: '表示ラベル'}``` というものになっているのを想定しています。

* placeholder: 何も入力されていないテキスト入力のプレースホルダの文字列
* getSuggestions(key): 現在入力中の文字列を受け取り、候補の文字列のリストを返します。typeはリスト表示のときにCSSクラスとして付与されます
* getCurrentFilters(): 現在選択されているリストを返します。選択状態の保存はコンポーネント利用する側の責務です。FilterInputは状態を持たないので、このコールバックを使ってリストを取得します。リストの形式はgetSuggestionsと同じです。
* onAddFilter(item): 入力が確定されたあとに呼ばれます。
* onRemoveFilter(item): 削除される時に呼ばれます。

利用側のサンプルとしては次の通りです。補完の最後の手触り的な部分はいろいろコールバック内でカスタマイズできます。

```js
// 候補のテキストのリスト
const languages = ['C++'...];

    controller: function() {
       // 選択されているテキストのリストを格納
       this.languages = m.prop([]);
       
       // 補完候補を返すコールバック
       this.wordSuggestions = (key) => {
           // CaseInsensitiveの前方一致で一致したもののみを返す
           var suggestions = languages.filter(lang => {
               return lang.toLowerCase().startsWith(key);
           });
           // ただしひとつも一致しない時は、候補を全部返す
           if (suggestions.length === 0) {
               suggestions = languages;
           }
           // FilterInputが期待するデータに変換
           return suggestions.map(lang => {
               return {type: 'word', label: lang};
           });
       }

       // 選択されているデータを返すコールバック
       this.wordFilters = () => {
           // FilterInputが期待するデータに変換
           return this.languages().map(lang => {
               return {type: 'word', label: lang};
           });
       }
       // データ追加のコールバック
       this.addWord = (filter) => {
           // 今回は、オリジナルのリストにあるもの以外は拒絶する
           if (languages.indexOf(filter.label) === -1) {
               return;
           }
           // 今回は、重複入力を認めない
           if (this.languages().indexOf(filter.label) === -1) {
               this.languages().push(filter.label);
           }
       }
       // データ削除のコールバック
       this.removeWord = (filter) => {
           const index = this.languages().indexOf(filter.label);
           if (index !== -1) {
               this.languages().splice(index, 1);
           }
       }
    },
```

応用の使い方
--------------

候補を返す関数は今は即値を返す実装になっていますが、サーバアクセスして候補リストを取得することも可能です。その場合はコールバック自体はプロパティに保存した候補データを返すけど、テキストが変更されていたら別途m.requestでサーバアクセスして、候補データを更新する、といった方法が考えられます。