Vanilla TypeScriptとWAI-ARIAで作るタブUI(タブ切り替え)
タブ UI を素の TypeScript(JavaScript)と WAI-ARIA で実装する方法についてまとめました。
主に HTML ベースでサイトのコンテンツを運用更新したい場合や特定のライブラリやフレームワークに依存したくない場合で参考にしていただけるかと思います。
この記事で行うこと
この記事では以下の言語や仕様を使ってタブ UI を作成します。特に WAI-ARIA の利用が実装のコアになります。WAI-ARIA に対応することで、アクセシブルなコンポーネント実装を目指します。
- HTML
- CSS
- TypeScript(JavaScript)
- WAI-ARIA
HTML マークアップ
HTML ファイルを作成し、以下のようにマークアップします。コンポーネントのラッパーに特定の ID を付与します。ここではsample-tab
としました。また、パネルコンテンツID
, role
, aria-controls
, aria-selected
, aria-hidden
, tabindex
の利用が必須となります。
<div id="sample-tab">
<!-- タブ一覧 -->
<ul role="tablist">
<li role="presentation">
<button
role="tab"
aria-controls="panel1"
aria-selected="true"
tabindex="0"
>
Tab 1
</button>
</li>
<li role="presentation">
<button
role="tab"
aria-controls="panel2"
aria-selected="false"
tabindex="-1"
>
Tab 2
</button>
</li>
<li role="presentation">
<button
role="tab"
aria-controls="panel3"
aria-selected="false"
tabindex="-1"
>
Tab 3
</button>
</li>
</ul>
<!--// タブ一覧 -->
<!-- タブパネルコンテンツ -->
<div id="panel1" role="tabpanel" aria-hidden="false" tabindex="0">
<p>Panel1 Content</p>
</div>
<div id="panel2" role="tabpanel" aria-hidden="true" tabindex="0">
<p>Panel2 Content</p>
</div>
<div id="panel3" role="tabpanel" aria-hidden="true" tabindex="0">
<p>Panel3 Content</p>
</div>
<!--// タブパネルコンテンツ -->
</div>
aria-controls の指定
aria-controls
の値はパネルコンテンツの ID 属性と紐づくように記述します。
aria-controls="panel1"
aria-selected の指定
aria-selected
はタブの選択状態を管理するために利用します。
aria-selected
はブール値で指定します。選択済みのタブにはtrue
を設定します。
aria-selected="true"
tabindex の指定
tabindex
はキーボード移動の順番を制御するために利用します。
tabindex
には0
もしくは−1
を設定します。タブ一覧部分は選択済みのタブに0
を指定し、他は-1
にします。タブパネルコンテンツではtabindex
をすべて0
にします。
tabindex="0"
aria-hidden の指定
aria-hidden
はタブパネルコンテンツの表示状態を管理するために利用します。
展開されているコンテンツにはfalse
を指定し、非表示のコンテンツにはtrue
を指定します。
aria-hidden="false"
CSS による表示制御
視覚情報のコンテンツ表示制御には CSS を用います。
以下のようにaria-*
を利用してスタイリングします。
[aria-hidden="true"] {
display: none;
}
[aria-hidden="false"] {
display: block;
}
TypeScript でのコンポーネント制御
ユーザー操作に応じてaria-*
を切り替えられるようにプログラムを記述します。ここでは再利用可能な処理にするため、tab 関数を作成しました。
引数にコンポーネントを特定するためにラッパーで設定した ID を指定します。
/**
* タブ切り替え関数
* @param { String } wrapperId - ラッパーID
* ex: tab('sample-tab');
*/
function tab(wrapperId: string): void {
const element = document.getElementById(wrapperId);
const tabList = element?.querySelector('[role="tablist"]');
const tabButtonList = element?.querySelectorAll('[role="tab"]');
const tabArrayList = [].slice.call(tabButtonList);
// Initialize tabFocus
const activeTab = element?.querySelector('[aria-selected="true"]') as HTMLButtonElement;
const indexNum = (tabArrayList as HTMLButtonElement[]).indexOf(activeTab);
let tabFocus = indexNum || 0;
// Toggle function
const toggleTab = (event: Event): void => {
const eventTarget = event.currentTarget as HTMLButtonElement;
const targetPanel = eventTarget.getAttribute('aria-controls');
const activeTab = element?.querySelector('[aria-selected="true"]');
const activeContent = element?.querySelector('[aria-hidden="false"]');
// Toggle tab's aria-selected
activeTab?.setAttribute('aria-selected', 'false');
activeTab?.setAttribute('tabindex', '-1');
eventTarget?.setAttribute('aria-selected', 'true');
eventTarget?.setAttribute('tabindex', '0');
const indexNum = (tabArrayList as HTMLButtonElement[]).indexOf(eventTarget);
tabFocus = indexNum;
// Toggle content's aria-hidden
activeContent?.setAttribute('aria-hidden', 'true');
element?.querySelector(`#${targetPanel || 'not-supplied'}`)?.setAttribute('aria-hidden', 'false');
event.preventDefault();
};
// Tab click EventListener
tabButtonList?.forEach((item) => {
item.addEventListener('click', toggleTab);
});
// Keydown function
const keydownFocus = (event: KeyboardEventInit) => {
// Detect arrow direction
if (event.code === 'ArrowRight' || event.code === 'ArrowLeft') {
// Reset tabindex
tabButtonList && tabButtonList[tabFocus].setAttribute('tabindex', '-1');
// Move Right
if (tabButtonList && event.code === 'ArrowRight') {
tabFocus += 1;
// If you are at the end, go back to the start
if (tabFocus >= tabButtonList.length) {
tabFocus = 0;
}
} else if (tabButtonList && event.code === 'ArrowLeft') {
tabFocus -= 1;
// If you are at the start, move to the end
if (tabFocus < 0) {
tabFocus = tabButtonList.length - 1;
}
}
// Change tabindex
const tabFocused = tabButtonList && (tabButtonList[tabFocus] as HTMLButtonElement);
tabFocused && tabFocused.setAttribute('tabindex', '0');
tabFocused && tabFocused.focus();
}
};
// Tab keydown EventListener
tabList?.addEventListener('keydown', keydownFocus);
}
タブ選択操作による制御
タブクリック時にaria-selected
, tabindex
, aria-hidden
を切り替えるためにclick
イベントを登録します。属性値の切り替えにはsetAttribute
を利用します。
// Tab click EventListener
tabButtonList?.forEach((item) => {
item.addEventListener("click", toggleTab);
});
キーボード操作による制御
右矢印、左矢印のキーボード操作でフォーカスの移動ができるようkeydown
のイベントを登録します。
// Tab keydown EventListener
tabList?.addEventListener("keydown", keydownFocus);
KeyboardEvent
は以下のようにcode
プロパティで取得できます。
if (event.code === "ArrowRight" || event.code === "ArrowLeft") {
// 省略
}
右にフォーカスを移動する事にtabFocus
の値を 1 ずつ増やし、左に移動したさいには 1 ずつ減らします。また、タブのフォーカス移動が先頭もしくは末尾を超えた場合はループでフォーカス移動するよう設定しています。
tab 関数の実行
以下のように関数を実行すればロジックの制御まで実装完了です。
tab("sample-tab");
デモ
以上の実装にコンポーネントの装飾を少し加えたものが次のデモになります。
タブクリックでのパネル展開はもちろんのこと、左右のキーボード操作でフォーカス移動できることを確認いただけます。
まとめ
受託 Web 制作の現場だとクライアントが HTML を更新するケースも多く、JSX などで TypeScript(JavaScript)にコンテンツ要素を入れることが難しくなります。
そのような場合には、今回紹介したアプローチを検討いただけると幸いです。
Discussion