jQuery : セレクタフィルタの様々な罠

月花です。

チェックボックスjQueryでわちゃわちゃしていたときにハマったのでメモします。
jQuery、卒業したいですね。

前提

こういうフォームがあります。

<!doctype html>
    <body>
        <form name="hoge">
            <label><input type="checkbox" name="fruits[]" value="4種盛り合わせ">4種盛り合わせ</label>
            <label><input type="checkbox" name="fruits[]" value="3種盛り合わせ">3種盛り合わせ</label>
            <label><input type="checkbox" name="fruits[]" value="レモン">レモン</label>
            <label><input type="checkbox" name="fruits[]" value="オレンジ">オレンジ</label>
            <label><input type="checkbox" name="fruits[]" value="ぶどう">ぶどう</label>
            <label><input type="checkbox" name="fruits[]" value="なし">なし</label>
        </form>
    </body>
</html>

果物を選択するチェックボックスが6つ並んでいます。

問題はこの「盛り合わせ」という2つの選択肢で、これに関しての仕様として、
1. チェックされたら、盛り合わせ以外の他の選択肢のチェックを外す
2. チェックされている間は、盛り合わせ以外の他の選択肢をチェックできない
3. ただし盛り合わせ同士は相互にチェックし合えるが、片方を押したら片方が非選択に戻る
ということになっています。

しかもなんとこのチェックボックス、上位フレームワークの影響でid属性を付けることはできません。
さらに、value値はなんと日本語です。勘弁してほしい。

セレクタだけで直接1要素を拾うことができず、value値が日本語で比較もあんまりしたくないので、フィルタを駆使して、上記を実装します。

:first は使えない

仕様の、3 が邪魔をして、:firstだけでなく、2番目を取る必要があります。
2番目をビシっと抜き出す、:second があれば良かったんですが、無いので、筆者は:nth-child()を使うことにしました。

わかってる人はアレっと思うでしょうが、後述ですし、そういう人にとって為になる情報はこの記事にはありません。

:nth-child() も使えない

とりあえずjQueryで1番目のチェックボックスが拾えれば、2番目も同様に拾えるし、なんとかなるやろってことで、やってみます。

$('input[name^=fruits]:nth-child(1)').change( function(){
    alert($(this).val());
});

しかしこれはうまく行きません。
1番目だけでなく、どこを押しても押した要素が拾われてしまいます。

これは、inputタグは全てそれぞれのlabelタグの中に入っているためです。
:nth-child() は親要素の長子を1とし、次子を2というように指定するのですが、それぞれのinputはそれぞれのlabelの長子なので、全てのinputが適合してしまい、全てのinputにchangeイベントがバインドされる結果となりました。

:nth-of-type()も、名前にchildが入っていないのですが、これは子要素のうち、このフィルタがくっついているセレクタで絞り込むため、長子しかいないこの場合は結局同じです。

:eq()を使う

筆者は、:eq()を知りませんでした。
正確には、知ってはいたんですけど、「eqがイコールの省略なのはわかるけど、何と何を比べてんねん」と思って詳しく知りませんでした。

:eq() は、そのセレクタの、親子関係を気にせず、ページの上から順番に振った通し番号を比較します。
要するに、DOM配列のインデックスです。なので、 :first と :eq(0) は等価になります。

ここで筆者は、しばらく nth-child() について考えていたため、インデックスの振り方でハマりました。
:nth-child() は、何番目の子供か、という通し番号です。0番目の子供というのは存在しないため、1から始まります。
:eq() はインデックスです。添字なので0から始まります。 めんどくさいですね。

これを使って、

$(function(){
    $('input[name^=fruits]:eq(0)').change( function(){
        if ($(this).is(':checked')) {
            //チェックされたとき
        } else {
            //チェックが外されたとき
        }
    });
    $('input[name^=fruits]:eq(1)').change( function(){
        if ($(this).is(':checked')) {
            //チェックされたとき
        } else {
            //チェックが外されたとき
        }
    });
});

これで、1番目の要素と2番目の要素だけにchangeイベントがバインドできました。

以外 をセレクタで表現する

1番目と2番目の要素だけにchangeイベントをバインドすることに成功しました。
その中で、問題の選択肢以外、というセレクタが必要になってきます。

これは、 :not() で解決します。

$('input[name^=fruits]:eq(0)').change( function(){
    if ($(this).is(':checked')) {
        //チェックされたとき
        $('input[name^=fruits]:not(:eq(0))').prop('checked', false);
        $('input[name^=fruits]:not(:eq(0))').attr('disabled', 'disabled');
    }
});

このように、さきほどの :eq() を :not() の引数の中に押し込めてやります。
これで、フィルタを反転させて、押した要素以外の全てのチェックを外し、選択不可にしました。
しかし、問題の選択肢2つの間では自由に選択することができなければいけません。

そこで、 :not() を複数のフィルタで指定します。

$('input[name^=fruits]:eq(0)').change( function(){
    if ($(this).is(':checked')) {
        //チェックされたとき
        $('input[name^=fruits]:not(:eq(0))').prop('checked', false);
        $('input[name^=fruits]:not(:eq(0),:eq(1))').attr('disabled', 'disabled');
    }
});

:not() の引数は、カンマ区切りでOR条件で複数のフィルタを指定できます。
この結果、1番目と2番目以外を選択不可にするということが実現できました。
ちなみに、半角スペースで区切ると、AND条件になります。

今回は、1番目でもなく かつ 2番目でもない、となればよいのでOR条件を使って ¬(1∨2) とし、実質 (¬1)∧(¬2) なので実現できています。
ド・モルガンの法則です。頻繁に使うんですが、普段は名前を意識しないので、名前を言われると懐かしさを感じますよね。

チェックが外されたときの挙動を追加して

$('input[name^=fruits]:eq(0)').change( function(){
    if ($(this).is(':checked')) {
        //チェックされたとき
        $('input[name^=fruits]:not(:eq(0))').prop('checked', false);
        $('input[name^=fruits]:not(:eq(0),:eq(1))').attr('disabled', 'disabled');
    }else{
        //チェックが外されたとき
        $('input[name^=fruits]').removeAttr('disabled');
    }
});

2番目の要素にも同様にバインド

$('input[name^=fruits]:eq(0)').change( function(){
    if ($(this).is(':checked')) {
        //チェックされたとき
        $('input[name^=fruits]:not(:eq(0))').prop('checked', false);
        $('input[name^=fruits]:not(:eq(0),:eq(1))').attr('disabled', 'disabled');
    }else{
        //チェックが外されたとき
        $('input[name^=fruits]').removeAttr('disabled');
    }
});
$('input[name^=fruits]:eq(1)').change( function(){
    if ($(this).is(':checked')) {
        //チェックされたとき
        $('input[name^=fruits]:not(:eq(1))').prop('checked', false);
        $('input[name^=fruits]:not(:eq(0),:eq(1))').attr('disabled', 'disabled');
    }else{
        //チェックが外されたとき
        $('input[name^=fruits]').removeAttr('disabled');
    }
});

共通化できそうなんですけど、共通化すると同じ要素に2度バインドしたりしてしまうのでうまく行きませんでした。
もう少しスマートにできる気はするんですけど。

最終的にはこう

<!doctype html>
    <head>
        <script
            src="https://code.jquery.com/jquery-3.1.1.slim.min.js"
            integrity="sha256-/SIrNqv8h6QGKDuNoLGA4iret+kyesCkHGzVUUV0shc="
            crossorigin="anonymous"></script>
        <script type="text/javascript">
            $(function(){
                $('input[name^=fruits]:eq(0)').change( function(){
                    if ($(this).is(':checked')) {
                        $('input[name^=fruits]:not(:eq(0))').prop('checked', false);
                        $('input[name^=fruits]:not(:eq(0),:eq(1))').attr('disabled', 'disabled');
                    } else {
                        $('input[name^=fruits]').removeAttr('disabled');
                    }
                });
                $('input[name^=fruits]:eq(1)').change( function(){
                    if ($(this).is(':checked')) {
                        $('input[name^=fruits]:not(:eq(1))').prop('checked', false);
                        $('input[name^=fruits]:not(:eq(0),:eq(1))').attr('disabled', 'disabled');
                    } else {
                        $('input[name^=fruits]').removeAttr('disabled');
                    }
                });
            });
        </script>
    </head>
    <body>
        <form name="hoge">
            <label><input type="checkbox" name="fruits[]" value="4種盛り合わせ">4種盛り合わせ</label>
            <label><input type="checkbox" name="fruits[]" value="3種盛り合わせ">3種盛り合わせ</label>
            <label><input type="checkbox" name="fruits[]" value="レモン">レモン</label>
            <label><input type="checkbox" name="fruits[]" value="オレンジ">オレンジ</label>
            <label><input type="checkbox" name="fruits[]" value="ぶどう">ぶどう</label>
            <label><input type="checkbox" name="fruits[]" value="なし">なし</label>
        </form>
    </body>
</html>

終わりです。

ついでに色々調べてたんですけど、セレクタって結構やるやつですね。
jQueryの是非はともかく、ちゃんと論理演算を考えながら実装すれば、行数とバインドの節約が捗りそうです。
とはいっても、仕様変更・追加で継ぎ足し継ぎ足しのjQuery、まとめて改善しようにもすでに稼働しているので余計なテストが・・・。厄介すぎる・・・。

参考文献

www.jquerystudy.info