Vue.jsでデータバインド可能な3-stateチェックボックスを作る

WEBのチェックボックスは通常チェック有・無の2-stateですが、HTML5からは中間(不定)状態を加えた3-stateのチェックボックスを簡単に作ることができます。
中間状態にするにはチェックボックスの"indeterminate"プロパティをtrueにすればよいのですが、このプロパティはJavascriptのコードからでないと設定できません。
具体的にはこんな感じです。これで画面のクリック操作ではちゃんと3-stateが実現できています。

一方、WEBアプリを含むGUIを持つプログラムの設計パターンとして"MVVM"というものがあります。
MVVMはモデル(M)とビュー(V)を「データバインド」機能で同期することによりコードの記載量や複雑性を減らしたり単体テストがしやすくなるというデザインパターンですが、先ほど作った3-stateチェックボックスのままではその3状態をモデルにバインドすることができません。
今回はVue.jsというMVVMフレームワークでデータバインド可能な3-stateチェックボックスを実装してみます。

結論からいうと、Vue.jsではカスタムディレクティブという機能を用いて実装する必要がありました。

キモであるカスタムディレクティブの実装はこんな感じです。今回は"three-state"という名前にしてあります(ディレクティブ名としては"v-three-state")。

Vue.directive('three-state', {
    twoWay: true,
    bind: function() {
        this.handler = function() {
            var val = threeStateByClick(this.el);
            this.set(val);
        }.bind(this);

        this.el.addEventListener('click', this.handler);
    },
    unbind: function() {
        this.el.removeEventListener('click', this.handler);
    },
    update: function(val) {
	threeStateByVal(this.el, val);
    },
});

このカスタムディレクティブの機能は、

  • ビューの変更(クリック)をイベントリスナーでキャッチし、モデルに反映する:bind→this.handler
  • モデルの変更を検知し、ビューに反映する:update

の2つです。

まずはbindでビューの変更を検知するためのイベントハンドラーthis.handler()を登録します。
イベントハンドラーでは外部のthreeStateByClick()メソッドを呼び出します。対象のDOMは(カスタムディレクティブが指定されていれば)this.elで取得することができますので、それを引数としています。

threeStateByClick()メソッドは最初に実装した3-stateチェックボックスを実現するJavascriptとほぼ同じですが、返り値としてクリック後の状態を示す値を返すように拡張されています。
チェック有・無は従来のチェックボックス同様true/falseで、中間状態はnullという値にしてあります。

function threeStateByClick(cb, valOnly) {
    var val = true;
    if (cb.readonly) { // 本来はcb.indeterminate(値が保持されないのでreadonlyを代用)
        if (!valOnly) cb.readonly = cb.indeterminate = false;
        val = cb.checked = false;
    }
    else if (!cb.checked) {
        if (!valOnly) cb.readonly = cb.indeterminate = true;
        val = null;
    }
    return val;
}

コメントにもあるとおりindeterminateプロパティは値をセットすることはできますが保持できない(設定した値が読み取れない)ため、readonlyプロパティに常に同じ値を設定しておくことにより代用します(チェックボックスのreadonlyプロパティは実用上無意味なので流用しても問題無し)。
呼び出し元のカスタムディレクティブ側ではこの返り値を使ってthis.set()を実行することでモデルに対し変更後の値を設定します。
なお、this.set()を使うためにはディレクティブで"twoWay: true"を指定する必要があります。

今度は逆にモデルが変更された場合です。値が変更されたことはカスタムディレクティブのupdateで検知し、外部のthreeStateByVal()メソッドを呼び出します。

function threeStateByVal(cb, val) {
    if (val === null) {
        cb.readonly = cb.indeterminate = true;
        cb.checked = false;
    }
    else {
        cb.readonly = cb.indeterminate = false;
        cb.checked = val;
    }
}

threeStateByVal()メソッドはthreeStateByClick()の逆を行う、つまり値からチェックボックスのプロパティを設定するものです。
ただしthreeStateByClick()が「クリックされることにより更新される次の状態への変更」なのに対して、threeStateByVal()は「指定された値の状態への変更」なので完全な逆関数ではありません。

これで双方向bindが可能なカスタムディレクティブが実装できました。
あとはHTMLで対象となるチェックボックスにv-three-state="バインドしたい変数名"を追加するだけです。

    <input type="checkbox" v-three-state="cbVal" />

サンプルではモデルとのbind状態がよく分かるようにリストボックスと連動するようにしてあります。
3-stateチェックボックスのチェック状態がリストボックスに反映されること、及びその逆であるリストボックスを選択するとチェックボックスの状態に反映されることを確認してみてください。