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、まとめて改善しようにもすでに稼働しているので余計なテストが・・・。厄介すぎる・・・。