はじめに
複数のコンテンツを並列にグループ化し各コンテンツに紐づいたタブ要素をアクティブにすることでコンテンツを切り替えるUI、タブUI。
モーダルやカルーセルなどのUIと比べると実装難易度は低めのUIかと思いますが、特にキーボード操作周りで是非に満たしたい非機能要件がいくつかあります。
今回はその非機能要件にフォーカスをあて、具体例をあげながら実装方法を見ていきます。
(ほとんどまるっとWAI-ARIA Authoring Practices 1.2の写経です)
完成形
まずは非機能要件を意識せずに作ったタブUIです。
See the Pen Untitled by Men_Tori (@ueda-kio) on CodePen.
そしてこちらが非機能要件を意識して作ったタブUIです
See the Pen Tab UI - Accessible by Men_Tori (@ueda-kio) on CodePen.
ぱっと触った限りでは、両者の違いはわからないかと思います。
そんな「ぱっと触った限りではわからない部分のお話」ということになります。
機能要件
まずはタブUIの一般的な機能要件を確認しましょう。Authoring Practices 1.2から引用します。
Tabs are a set of layered sections of content, known as tab panels, that display one panel of content at a time. Each tab panel has an associated tab element, that when activated, displays the panel. The list of tab elements is arranged along one edge of the currently displayed panel, most commonly the top edge.
タブは、タブパネルと呼ばれるコンテンツのレイヤーセクションのセットで、一度に1つのパネルを表示します。各タブパネルには、関連するタブ要素があり、これをアクティブにするとパネルが表示されます。タブエレメントのリストは、現在表示されているパネルの一辺(通常は上辺)に沿って配置されます。
(DeepLにより翻訳)
- タブパネルに紐づいたタブが存在し、タブをアクティブにするとタブパネルが表示される
- 一度に表示されるタブパネルは1つ
- タブのリストは表示されているタブパネルの上辺に沿って配置されている
くらいでしょうか。
シンプルですし我々の思っている通りの機能かと思います。
(以下、本記事ではクリック可能なコンテンツを切り替えるトリガーの部分を「タブ」、切り替わるコンテンツ部分を「タブパネル」と呼ぶことにします。)
class Tab {
constructor() {
this.tabs = tabs; // タブ
this.tabPanels = tabPanels; // タブパネル
this.activeIndex = this.tabPanels.findIndex((elm) =>
elm.classList.contains('is-active')
); // 初期表示時にアクティブなタブのインデックス
this.activeTab = this.tabs[this.activeIndex]; // 現在アクティブなタブ
this.activeTabPanel = this.tabPanels[this.activeIndex]; // 現在アクティブなタブパネル
this.#handleClick();
}
#handleClick() {
this.tabs.forEach((tab, i) => {
tab.addEventListener('click', (e) => {
const target = e.currentTarget;
if (target === this.activeTab) return;
const targetPanel = this.tabPanels[i];
target.classList.add('is-active');
targetPanel.classList.add('is-active');
this.activeTab.classList.remove('is-active');
this.activeTabPanel.classList.remove('is-active');
this.activeIndex = i;
this.activeTab = target;
this.activeTabPanel = targetPanel;
});
});
}
}
完成形でお見せした「非機能要件を意識せずに作ったタブUI」のjsのソースとしてはこんな感じです。(一部省略しています)
アクティブなタブ・タブパネルにはis-active
クラスを付与しスタイルを定義、非アクティブなタブをクリックすることでタブ・タブパネルのis-active
クラスをトグルして表示を切り替えます。
前述した機能要件はこれで満たせているので、非機能要件のことを考えなければこれで問題ないとも思えます。
では、ここからどのような非機能要件をどのように実装するのか見ていきます。
非機能要件
以下の要件を実装していきます。
-
role
属性の追加 -
ARIA
属性の追加 - キーボード操作のサポート
【非機能要件①】role
属性の設定
タブ・タブパネルそれぞれを示すhtmlの要素(<tab></tab>
のようなもの)はないので、マークアップ自体はdiv
, span
などで実装することになります。その上で、タブUIに適したrole
属性がWAI-ARIA1に用意されているのでそれらをありがたく使っていきます。
(role
属性についての詳細は避けますが「html標準の要素だけでは説明しきれない、その要素の役割を示すための属性」という認識でよいかと)
まずはタブのラッパー要素にrole="tablist"
を、タブ自身にはrole="tab"
を付与します。
そしてタブパネルにはrole="tabpanel"
を付与します。
<div class="tabList" role="tablist">
<div id="tab-1" class="tabList__tab is-active" role="tab">tab-1</div>
<div id="tab-2" class="tabList__tab" role="tab">tab-2</div>
<div id="tab-3" class="tabList__tab" role="tab">tab-3</div>
</div>
<div class="tabPanle is-active" role="tabpanel">...</div>
<div class="tabPanle" role="tabpanel">...</div>
<div class="tabPanle" role="tabpanel">...</div>
これでマシン・ブラウザに「この要素たちはタブUIなんですよ」ということを伝えることができました。
role="tablist"
role="tab"
role="tabpanel"
[余談...]
-
ul
,li
要素でリスト化しなくてもよい
「リスト構造なのだからul
,li
要素で実装する方が適しているのでは?」と感じる方もいるかもしれません。が、その必要はありません。
ul
要素はrole="list"
、li
要素はrole="listitem"
というロールを持っていますが、これらよりもrole="tablist"
,role="tab"
の方が適したロールであり、かつロールはrole
属性により上書きされるため、div
,span
要素などのなんのロールも持たない要素でマークアップしても問題ありません。もちろん、ul
,li
要素でマークアップしてもかまいません。 -
タブは
button
要素でマークアップしなくてもよい
「タブはインタラクティブなUIなのでbutton
要素で実装する方が適しているのでは?」と感じる方もいるかもしれません。が、上記と同様の理由でその必要はありません。
button
要素が持っているbutton
ロールは結局role="tab"
によって上書きされるため、必ずしもbutton
要素でマークアップしなくても問題ありません。現に今回はbutton
要素のデフォルトスタイルをリセットするのが面倒だったのでdiv
要素でマークアップしました。
が、ロールだけの問題ではなく、Tabキーでアクセス可能だったりSpace, Enterキーでアクティブにできたりと、button
要素はブラウザ標準で様々な機能を持っているためdiv
でマークアップするからにはその辺りも漏らさずに自分で実装する必要があります。(その辺りは後述で実装します)
【非機能要件②】ARIA
属性の設定
role
属性以外にも、付与するべき属性がいくつかあります。
tabindex
属性
(厳密にはWAI-ARIAの機能ではありませんが...)
tabindex
属性はTabキーでのフォーカス順序を決定する属性で、主に"0" | "-1"
の値で「Tabキーでフォーカスさせるかどうか」の制御に用いる属性ですが、こちらをタブ・タブパネル両方に指定します。
また、非アクティブなタブにはTabキーでフォーカスできないようにするため、アクティブなタブのみにtabindex="0"
を指定します。
タブをbutton
要素などデフォルトでTabアクセスが可能な要素でマークアップした場合、非アクティブなタブにtabindex="-1"
を付与して上書きする必要があります。
<div class="tabList" role="tablist">
<div id="tab-1" class="tabList__tab" role="tab" tabindex="0">tab-1</div>
<div id="tab-2" class="tabList__tab" role="tab">tab-2</div>
<div id="tab-3" class="tabList__tab" role="tab">tab-3</div>
</div>
<div class="tabPanle is-active" role="tabpanel" tabindex="0">...</div>
<div class="tabPanle" role="tabpanel" tabindex="0">...</div>
<div class="tabPanle" role="tabpanel" tabindex="0">...</div>
なぜ非アクティブなタブはTabキーでアクセスできないようにしているかという理由は、Tabキーでの移動順序が「タブ→タブパネル」とするためです。
非アクティブなタブにもtabindex="0"
を付与するとTab移動の順序が「アクティブなタブ→非アクティブなタブ」となってしまい、それを避けるためです。
「それでは非アクティブなタブにキーボードでアクセスできず、問題ではないのか」と思われるかもしれませんが、大丈夫です。
詳細は後述しますが、方向キーを使用して非アクティブなタブも操作可能にします。
aria-controls
, aria-labelledby
, aria-selected
属性
タブ・タブパネルの紐付けやラベル付け、どのタブがアクティブになっているかの指定など、各種ARIA
属性を用いて示していきます。
以下が完成形です。
<div class="tabList" role="tablist">
<div id="tab-1" class="tabList__tab is-active" role="tab" aria-selected="true" aria-controls="tabpanel-1">tab-1</div>
<div id="tab-2" class="tabList__tab" role="tab" aria-selected="false" aria-controls="tabpanel-2">tab-2</div>
<div id="tab-3" class="tabList__tab" role="tab" aria-selected="false" aria-controls="tabpanel-3">tab-3</div>
</div>
<div id="tabpanel-1" class="tabPanel is-active" role="tabpanel" aria-labelledby="tab-1" tabindex="0">...</div>
<div id="tabpanel-2" class="tabPanel" role="tabpanel" aria-labelledby="tab-2" tabindex="0">...</div>
<div id="tabpanel-3" class="tabPanel" role="tabpanel" aria-labelledby="tab-3" tabindex="0">...</div>
-
紐付け
まずタブ・タブパネルを紐づける、つまり「このタブをアクティブにするとこのタブパネルが表示される」ということを示すために、タブにaria-controls
属性を付与しその値に該当するタブパネルのid
属性を挿入します。
これで紐付けができました。 -
ラベル付け
次にタブパネル内のコンテンツの内容を示すために、タブパネルにaria-labelledby
属性を付与しその値にタブのid
を挿入します。これによりタブパネルのコンテンツの内容が、タブの文字列によって説明されることになります。
これでラベル付けができました。
(なお例ではタブの文字列が「tab-1」となっており、コンテンツの内容がさっぱりわからないため適していません。) -
アクティブタブの指定
最後に「現在どのタブがアクティブになっているか」を示すために、アクティブなタブにaria-selected="true"
を付与し、非アクティブなタブにaria-selected="false"
を付与します。
これでアクティブなタブを示すことができました。
aria-controls
aria-labelledby
aria-selected
[余談...]
-
is-active
クラスでは「アクティブであること」を示せていない
「そのタブがアクティブであることはis-active
クラスで示せているのでは?」と感じる方もいるかもしれません。が、残念ながら示せていません。
クラスはあくまでスタイルを指定するための識別子にすぎずhtmlの構造上の意味はなにもありません。そのため、しっかりとマシン・ブラウザに「このタブが今アクティブですよ」と示すためaria-selected="true"
属性を指定します。 -
aria-hidden
は不要
「非アクティブなタブパネルにaria-hidden="true"
の指定が必要では?」と感じる方もいるかもしれません。が、不要です。
aria-hidden="true"
は付与することによってアクセシビリティAPIから隠蔽することができスクリーンリーダーなどの読み上げをスキップすることができる属性で、確かに非表示の要素には付与するべき属性です。が、 cssのスタイルによってdisplay: none;
が当たっている要素はそれだけでアクセシビリティAPIから隠蔽されているため、aria-hidden="true"
の指定は重複した指定となってしまいます。 -
タブの機能要件は自分で実装する必要がある
注意していただきたいのは「紐付け」や「アクティブタブの指定」をARIA
属性で指定したからといって、ブラウザがよしなにタブ機能を実現してくれるわけではありません。
これらはあくまで要素の状態や性質を説明するためのものにすぎないため、タブの機能はjsなりで実装する必要があります。
これらの属性はインタラクティブに変化させる必要があるため、js側にも追加実装が必要です。
is-active
クラスの付け替えだけでなく、各種ARIA
属性の付け替えも行いましょう。
tab.addEventListener('click', (e) => {
const target = this.tabs[i];
if (target === this.activeTab) return;
const targetPanel = this.tabPanels[i];
+ target.setAttribute('aria-selected', 'true');
+ target.setAttribute('tabindex', '0');
targetPanel.classList.add('is-active');
+ this.activeTab.setAttribute('aria-selected', 'false');
+ this.activeTab.removeAttribute('tabindex');
this.activeTabPanel.classList.remove('is-active');
this.activeIndex = i;
this.activeTab = target;
this.activeTabPanel = targetPanel;
});
【非機能要件③】キーボード操作のサポート
さて、こちらがもっとも大事な非機能要件と言っても過言ではないかもしれません、キーボード操作のサポートです。
すべてのインタラクティブなUIはマウスがなくても操作可能、つまりキーボードだけで操作可能である必要があります2。タブUIも例外ではありません。
ということで、キーボードだけでタブの切り替えが可能になるように以下の要件を満たしていきます。
- 方向キー左右でタブの切り替えが可能(垂直タブの場合方向キー上下)
Home
,End
キーで最初・最後のタブパネルを表示する
アクティブタブにフォーカスがあるとき、方向キー左右でタブを切り替えます。
また、最初(最後)のタブがアクティブのとき左(右)キーを押下すると最後(最初)のタブがアクティブになります。つまりぐるぐるとループする形になります。(垂直タブの場合、方向キー上下で同じ機能)
また、こちらは非機能要件の中でもさらにoptionalな要件ではあるのですが、アクティブタブにフォーカスがあるときに Home
, End
キーを押下すると最初(最後)のタブをアクティブにするという要件もあります。
これらの機能により、キーボードのみでもタブUIの操作が可能となります。
では実装方法を見ていきましょう。
constructor() {
// 一部抜粋
this.maxIndex = tabs.length - 1;
...
}
/**
* @param {number} index
*/
#changeActiveTab(index) {
const targetTab = this.tabs[index];
// 以下これまでに実装したクリックイベントハンドラと同様
}
#handleClick() {
this.tabs.forEach((tab, index) => {
tab.addEventListener('click', () => {
this.#changeActiveTab(index);
});
});
}
#handleKeyPress() {
for (const tab of this.tabs) {
tab.addEventListener('keydown', (e) => {
switch (e.key) {
case 'ArrowRight': {
const targetIndex = (() => {
if (this.activeIndex === this.maxIndex) {
return 0; // アクティブタブが最後の場合最初のタブを表示
}
return this.activeIndex + 1;
})();
this.#changeActiveTab(targetIndex);
break;
}
case 'ArrowLeft': {
const targetIndex = (() => {
if (this.activeIndex === 0) {
return this.maxIndex; // アクティブタブが最初の場合最後のタブを表示
}
return this.activeIndex - 1;
})();
this.#changeActiveTab(targetIndex);
break;
}
case 'Home': {
const targetIndex = 0;
this.#changeActiveTab(targetIndex);
break;
}
case 'End': {
const targetIndex = this.maxIndex;
this.#changeActiveTab(targetIndex);
break;
}
default:
return;
}
});
}
}
click
イベントとkeydown
イベントに同じ処理を登録するため、タブ切り替え処理の部分をchangeActiveTab
という関数に切り出しています。
keydown
イベントのハンドラに渡されるEvent
オブジェクトのkey
プロパティを参照し、アクティブタブの切り替えを操作しています。
最後にソースコードの全体を載せておきます。
[クリックすると展開]
<section class="section">
<div class="tabList" role="tablist">
<div id="tab-1" class="tabList__tab is-active" role="tab" aria-selected="true" aria-controls="tabpanel-1" tabindex="0">tab-1</div>
<div id="tab-2" class="tabList__tab" role="tab" aria-selected="false" aria-controls="tabpanel-2">tab-2</div>
<div id="tab-3" class="tabList__tab" role="tab" aria-selected="false" aria-controls="tabpanel-3">tab-3</div>
</div>
<div>
<div id="tabpanel-1" class="tabPanel is-active" role="tabpanel" aria-labelledby="tab-1" tabindex="0">
<!-- contents -->
</div>
<div id="tabpanel-2" class="tabPanel" role="tabpanel" aria-labelledby="tab-2" tabindex="0">
<!-- contents -->
</div>
<div id="tabpanel-3" class="tabPanel" role="tabpanel" aria-labelledby="tab-3" tabindex="0">
<!-- contents -->
</div>
</div>
</section>
(() => {
'use strict';
class Tab {
/**
* @param {NodeListOf<Element>} tabs
*/
constructor(tabs) {
const tabPanels = [...tabs]
.map((tab) => {
const id = tab.getAttribute('aria-controls') ?? '';
return document.getElementById(id);
})
.filter((el) => !!el);
if (tabPanels.length !== tabs.length) return;
this.tabs = tabs;
this.tabPanels = tabPanels;
this.activeIndex = this.tabPanels.findIndex((elm) =>
elm.classList.contains('is-active')
);
this.activeTab = this.tabs[this.activeIndex];
this.activeTabPanel = this.tabPanels[this.activeIndex];
this.maxIndex = tabs.length - 1;
this.#init();
}
/**
* @param {number} index
*/
#changeActiveTab(index) {
const targetTab = this.tabs[index];
const targetPanel = this.tabPanels[index];
if (targetTab === this.activeTab) return;
targetTab.setAttribute('aria-selected', 'true');
targetTab.setAttribute('tabindex', '0');
targetTab.focus();
targetPanel.classList.add('is-active');
this.activeTab.setAttribute('aria-selected', 'false');
this.activeTab.removeAttribute('tabindex');
this.activeTabPanel.classList.remove('is-active');
this.activeIndex = index;
this.activeTab = targetTab;
this.activeTabPanel = targetPanel;
}
#handleClick() {
this.tabs.forEach((tab, index) => {
tab.addEventListener('click', () => {
this.#changeActiveTab(index);
});
});
}
#handleKeyPress() {
for (const tab of this.tabs) {
tab.addEventListener('keydown', (e) => {
switch (e.key) {
case 'ArrowRight': {
const targetIndex = (() => {
if (this.activeIndex === this.maxIndex) {
return 0;
}
return this.activeIndex + 1;
})();
this.#changeActiveTab(targetIndex);
break;
}
case 'ArrowLeft': {
const targetIndex = (() => {
if (this.activeIndex === 0) {
return this.maxIndex;
}
return this.activeIndex - 1;
})();
this.#changeActiveTab(targetIndex);
break;
}
case 'Home': {
const targetIndex = 0;
this.#changeActiveTab(targetIndex);
break;
}
case 'End': {
const targetIndex = this.maxIndex;
this.#changeActiveTab(targetIndex);
break;
}
default:
return;
}
});
}
}
#init() {
this.#handleClick();
this.#handleKeyPress();
}
}
const tabs = document.querySelectorAll('[role="tab"]');
new Tab(tabs);
})();
スタイルは省略しています。
実装例のスタイルが気になる方はCodePenのソースをご参照ください。
以上で非機能要件を意識したタブUIを作成することができました!
おわりに
冒頭でお伝えしている通り、今回紹介した非機能要件はすべてWAI-ARIA Authoring Practices 1.2内で紹介されているものです。私が勝手に「こういう要件あるといいヨネ」と言っているわけではありません()
余談ですが、チラリと確認したところMUIやChakra UIなどモダンなCSSフレームワークではこれら要件が満たされているようでした。(React-BootstrapはHome
キーによるタブ切り替えは実装されていなかったです)
WAI-ARIAを除くとキーボード操作周りの要件がほとんどで、スマホファーストな今の時代ではそこまでMUSTな機能とは言えなくなってきているのかもしれない、という所感でした。が、簡単なUIとはいえ色々と意識しなければいけない非機能要件はあるんだよ、という紹介がしたかったがために、今回記事にしてまとめました。
WAI-ARIA Authoring Practices 1.2ではタブUI以外にも様々なUIの非機能要件をまとめていますので、ぜひUI実装時には参考にしてみてください。
また、本記事で誤った内容がありましたらご指摘いただけますと幸いです。
参考
本記事作成にあたり参考にさせていただきました。ありがとうございました。