2022/12/23

アクセシビリティ対応済みタブ切替を最小限のHTMLで実装できるjQueryプラグイン

旧世代コーダーもいい加減キャッチアップが迫られているWAI-ARIA。
基本的にはセマンティックなマークアップをやっていれば大丈夫なところが多いし、:hoverでプルダウンが表示されるナビゲーションも:focus-withinという神のような疑似クラスが使えるようになったおかげで、CSSの微修正でなんとかなる。
がしかし動的なタブ切替は、HTMLに追記必須な属性がある。

ものの本を買ったりせずにウェブでWAI-ARIAについて調べると、これは必要なんか?というクラスやIDがタブなりコンテンツ要素なりその親要素なりにこまかく振られている例ばかり見つかって、要はどの部分が必須なのかわかりづらかった。極限まで絞ると結局こういうことであるらしい。(1番目のタブが選択されている状態の例)

<!-- タブ -->
<ul role="tablist">
  <li><button type="button" aria-controls="content_01" aria-selected="true">ボタン1</button></li>
  <li><button type="button" aria-controls="content_02" aria-selected="false">ボタン2</button></li>
  <li><button type="button" aria-controls="content_03" aria-selected="false">ボタン3</button></li>
</ul>

<!-- コンテンツ -->
<section id="content_01" aria-hidden="false">内容1</section>
<section id="content_02" aria-hidden="true">内容2</section>
<section id="content_03" aria-hidden="true">内容3</section>

スニペットをちまちま選んでるより手のが速いわ、という今どきレアな老兵には「打つ文字量が多え...」となりますね。
しかしそれなら、今までJSでのタブ切り替え実装のために(VueやReactとは無縁の世界の住人なので動的操作は当然のようにjQuery一択)振っていたクラスの代わりにaria属性を使って、ついでに固有値が絡むところ以外は入力を省略してJSに書かせてしまえばかなりスマートにできそうだな、いずれまとめないと~、と思っていたコードを、このほどようやくまとめてみた。
1ページ内に複数設置できて、選択済みタブをURLパラメータで指定できるようにもした(?tab=[ID])。&で複数つながっていても大丈夫。一応プラグインといえるだろうか。そのつもりなので、読めるギリギリまで極力ツメてコンパクトにしてある。

function tablist(){
  $("[role='tablist']").find("button").each(function(){let $t=$(this),c=$t.attr("aria-controls");
    $t.attr("type","button").attr("role","tab").attr("aria-selected","false"); $("#"+c).attr("role","tabpanel").attr("aria-hidden","true");
    $t.on("click",function(){if($t.attr("aria-selected")=="false"){
      $t.closest("[role='tablist']").find("[aria-selected='true']").attr("aria-selected","false"); $t.attr("aria-selected","true");
      $("#"+c).closest(".tabPanels").find("[aria-hidden='false']").attr("aria-hidden","true"); $("#"+c).attr("aria-hidden","false");
    }});
  });
  $("[role='tablist']").children("*:first-child").find("button").trigger("click");  // 初期状態
  let p=location.search; if(p.indexOf("tab=")!=-1){  // パラメータで初期表示タブ指定
    if(p.indexOf("&")!=-1){p=p.split("&"); for(let i=0;i<p.length;i++){if(p[i].indexOf("tab=")!=-1){$("[aria-controls='"+p[i].split("=")[1]+"']").trigger("click");}}}
    else{$("[aria-controls='"+location.search.split("?tab=")[1]+"']").trigger("click");}
  }
}

任意のページでこの関数tablistを実行するか、jQueryは対象要素が存在しなくてもエラーにならないので、なんなら共通JSファイルに書きっぱなしでも不具合は起きない。
これでいくと、HTMLは書くべき量がちょっと減る。

<!-- タブ -->
<ul role="tablist">
  <li><button aria-controls="content_01">ボタン1</button></li>
  <li><button aria-controls="content_02">ボタン2</button></li>
  <li><button aria-controls="content_03">ボタン3</button></li>
</ul>

<!-- コンテンツ -->
<div class="tabPanels">
  <section id="content_01">内容1</section>
  <section id="content_02">内容2</section>
  <section id="content_03">内容3</section>
</div>

切り替え対象コンテンツは.tabPanels要素内に同列で置く必要がある(JS内の該当箇所と一致させればクラス名はむろん変えても可)。それ以外はもともと必要な記述のみでいける。
JSではaria属性を操作しているだけなので、aria属性をCSSの属性セレクタとして利用できるHTML5時代に感謝しながら、以下の2行を共通CSSファイルに書き足すととりあえず動く。

.tabPanels [aria-hidden] {max-height:0; overflow:hidden;}
.tabPanels [aria-hidden="false"] {max-height:99999px;}

あとは別途クラスを振って自由に装飾をば。


後日註:その後ネイティブJS化にも成功しました。HTMLは同じ。

function tabList(){
  document.querySelectorAll("[role='tablist']").forEach(tl=>{
    Array.from(tl.querySelectorAll("button")).forEach(b=>{
      let ID = b.getAttribute("aria-controls"),a=document.querySelector(`#${ID}`);
      b.setAttribute("type","button");
      b.setAttribute("role","tab");
      b.setAttribute("aria-selected","false");
      a.setAttribute("role","a");
      a.setAttribute("aria-hidden","true");
      b.addEventListener("click",()=>{
        if(b.getAttribute("aria-selected")==="false"){
          let r=a.parentNode;
          Array.from(tl.querySelectorAll("[aria-selected='true']")).forEach(s =>{s.setAttribute("aria-selected","false");});
          Array.from(r.querySelectorAll("[aria-hidden='false']")).forEach(o =>{o.setAttribute("aria-hidden","true");});
          b.setAttribute("aria-selected","true");
          a.setAttribute("aria-hidden","false");
        }
      });
    });
    tl.querySelector("button").click();
  });
  let prm=new URLSearchParams(window.location.search);
  if(prm.has("tab")){
    let ID=prm.get("tab");
    document.querySelector(`[aria-controls='${ID}']`).click();
  }
}