アクセシビリティに配慮したタブメニューの実装例

広告
アクセシビリティに配慮したタブメニューの実装メモです。
タブメニューのHTML
前回投稿した記事「タブやアコーディオンの非表示コンテンツにはhidden=“until-found”を使うべし」で触れたように、タブパネルの非表示の状態管理はhidden="until-found"
、タブはa
要素で実装するようにします。
タブが横並びの場合
<div role="tablist"> <a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a> <a role="tab" id="tab2" href="#tabpanel2" aria-controls="tabpanel2" aria-selected="false" tabindex="-1">Tab2</a> <a role="tab" id="tab3" href="#tabpanel3" aria-controls="tabpanel3" aria-selected="false" tabindex="-1">Tab3</a></div><div role="tabpanel" id="tabpanel1" aria-labelledby="tab1" tabindex="0">コンテンツ1</div><div role="tabpanel" id="tabpanel2" aria-labelledby="tab2" hidden="until-found">コンテンツ2</div><div role="tabpanel" id="tabpanel3" aria-labelledby="tab3" hidden="until-found">コンテンツ3</div>
<ul role="tablist"> <li role="presentation"> <a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a> </li> <li role="presentation"> <a role="tab" id="tab2" href="#tabpanel2" aria-controls="tabpanel2" aria-selected="false" tabindex="-1">Tab2</a> </li> <li role="presentation"> <a role="tab" id="tab3" href="#tabpanel3" aria-controls="tabpanel3" aria-selected="false" tabindex="-1">Tab3</a> </li></ul><div role="tabpanel" id="tabpanel1" aria-labelledby="tab1" tabindex="0">コンテンツ1</div><div role="tabpanel" id="tabpanel2" aria-labelledby="tab2" hidden="until-found">コンテンツ2</div><div role="tabpanel" id="tabpanel3" aria-labelledby="tab3" hidden="until-found">コンテンツ3</div>
タブが縦並びの場合
<div role="tablist" aria-orientation="vertical"> <a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a> <a role="tab" id="tab2" href="#tabpanel2" aria-controls="tabpanel2" aria-selected="false" tabindex="-1">Tab2</a> <a role="tab" id="tab3" href="#tabpanel3" aria-controls="tabpanel3" aria-selected="false" tabindex="-1">Tab3</a></div><div role="tabpanel" id="tabpanel1" aria-labelledby="tab1" tabindex="0">コンテンツ1</div><div role="tabpanel" id="tabpanel2" aria-labelledby="tab2" hidden="until-found">コンテンツ2</div><div role="tabpanel" id="tabpanel3" aria-labelledby="tab3" hidden="until-found">コンテンツ3</div>
<ul role="tablist" aria-orientation="vertical"> <li role="presentation"> <a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a> </li> <li role="presentation"> <a role="tab" id="tab2" href="#tabpanel2" aria-controls="tabpanel2" aria-selected="false" tabindex="-1">Tab2</a> </li> <li role="presentation"> <a role="tab" id="tab3" href="#tabpanel3" aria-controls="tabpanel3" aria-selected="false" tabindex="-1">Tab3</a> </li></ul><div role="tabpanel" id="tabpanel1" aria-labelledby="tab1" tabindex="0">コンテンツ1</div><div role="tabpanel" id="tabpanel2" aria-labelledby="tab2" hidden="until-found">コンテンツ2</div><div role="tabpanel" id="tabpanel3" aria-labelledby="tab3" hidden="until-found">コンテンツ3</div>
解説
タブのリストである要素にはrole="tablist"
、タブとなる要素にはrole="tab"
を付与します。
<div role="tablist" aria-orientation="vertical"> <a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a> <a role="tab" id="tab2" href="#tabpanel2" aria-controls="tabpanel2" aria-selected="false" tabindex="-1">Tab2</a> <a role="tab" id="tab3" href="#tabpanel3" aria-controls="tabpanel3" aria-selected="false" tabindex="-1">Tab3</a></div>
role="tablist"
とrole="tab"
は親子関係である必要があります。ul
要素で実装する場合は前述の制約により、li
要素を「無いもの」として扱う必要があるのでrole="presentation"
を付与します。
これを怠るとVoiceOverではタブの個数の読み上げが行われなくなるので必ず指定するようにしましょう。
<ul role="tablist" aria-orientation="vertical"> <li role="presentation"> <a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a> </li> <li role="presentation"> <a role="tab" id="tab2" href="#tabpanel2" aria-controls="tabpanel2" aria-selected="false" tabindex="-1">Tab2</a> </li> <li role="presentation"> <a role="tab" id="tab3" href="#tabpanel3" aria-controls="tabpanel3" aria-selected="false" tabindex="-1">Tab3</a> </li></ul>
タブが横並びの場合は意識しなくても良いですが、縦並びの場合はrole="tablist"
にはaria-orientation="vertical"
を付与します。
<div role="tablist" aria-orientation="vertical"> <a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a> <a role="tab" id="tab2" href="#tabpanel2" aria-controls="tabpanel2" aria-selected="false" tabindex="-1">Tab2</a> <a role="tab" id="tab3" href="#tabpanel3" aria-controls="tabpanel3" aria-selected="false" tabindex="-1">Tab3</a></div>
タブパネルとなる要素にはrole="tabpanel"
を付与してタブパネルであることを明示します。
どの項目のパネルなのかを判別しやすくするために制御関係にあるタブのid
と紐づけたaria-labelledby
属性でラベリングも行います。
<div role="tablist"> <a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a> ...</div><div role="tabpanel" id="tabpanel1" aria-labelledby="tab1" tabindex="0">コンテンツ1</div>
タブと制御関係にあるタブパネルをタブパネルに指定したid
とタブのaria-controls
属性およびhref
属性で紐づけます。aria-controls
属性で制御関係にある要素を識別し、JSが無効化されている場合にもユーザーがタブパネルにアクセスできるリンクを提供するhref
属性も指定します。
<div role="tablist"> <a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a> ...</div><div role="tabpanel" id="tabpanel1" aria-labelledby="tab1" tabindex="0">コンテンツ1</div>
タブには現在選択されていることを示すためにaria-selected
属性を付与します。この場合だと最初のタブがtrue
に設定され、他のタブがfalse
に設定されています。aria-selected
属性の値はJSで切り替えます。
<div role="tablist"> <a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a> <a role="tab" id="tab2" href="#tabpanel2" aria-controls="tabpanel2" aria-selected="false" tabindex="-1">Tab2</a> <a role="tab" id="tab3" href="#tabpanel3" aria-controls="tabpanel3" aria-selected="false" tabindex="-1">Tab3</a></div>
タブとタブパネルにtabindex
属性を付与します。タブ側は選択中のtabindex
の値を0
に設定し、他のタブのそれは-1
にします。非アクティブのタブにtabindex="-1"
を付与することでスムーズにタブパネルへフォーカスを移すことができます。
MDNのサンプルでは全てのタブパネルにtabindex="0"
が付与されていますが、hidden="until-found"
は従来のhidden
属性とは違い非表示のrole="tabpanel"
要素にもフォーカスが当たるため、JSでアクティブなタブパネルのみにtabindex="0"
を付与するようにします。
<div role="tablist"> <a role="tab" id="tab1" href="#tabpanel1" aria-controls="tabpanel1" aria-selected="true" tabindex="0">Tab1</a> <a role="tab" id="tab2" href="#tabpanel2" aria-controls="tabpanel2" aria-selected="false" tabindex="-1">Tab2</a> <a role="tab" id="tab3" href="#tabpanel3" aria-controls="tabpanel3" aria-selected="false" tabindex="-1">Tab3</a></div><div role="tabpanel" id="tabpanel1" aria-labelledby="tab1" tabindex="0">コンテンツ1</div><div role="tabpanel" id="tabpanel2" aria-labelledby="tab2" hidden="until-found">コンテンツ2</div><div role="tabpanel" id="tabpanel3" aria-labelledby="tab3" hidden="until-found">コンテンツ3</div>
マークアップでrole="tab"
にtabindex="-1"
を付与してしまうとJS無効環境かつキーボード操作の時に非アクティブのタブを選択できなくなります。tabindex
属性はJS側で初期化時に付与するのが望ましいでしょう。
非表示のタブパネルにはhidden="until-found"
を付与します。display:none
する従来の方法とは違い、ページ内検索で検出することが可能になり、JS無効環境のフォールバックも容易になります。また、Chrome for Developersの文書によれば、hidden="until-found"
が指定された要素内のコンテンツには検索エンジンのロボットもアクセスできるようになるとのことですので、SEOに良い影響を与える可能性もあります。
<div role="tabpanel" id="tabpanel1" aria-labelledby="tab1" tabindex="0">コンテンツ1</div><div role="tabpanel" id="tabpanel2" aria-labelledby="tab2" hidden="until-found">コンテンツ2</div><div role="tabpanel" id="tabpanel3" aria-labelledby="tab3" hidden="until-found">コンテンツ3</div>
タブとタブパネルにそれぞれユニークなid
属性を指定する必要があります。Crypto: randomUUID() メソッドなどを利用し、ユニークIDを生成することをオススメします。
タブメニューのCSS
選択されているタブのスタイルの状態管理は[aria-selected='true']
セレクタで行うと良いでしょう。
[role='tab'][aria-selected='true'] { /* 選択中のタブのスタイル */}
@media (any-hover: hover) { [role='tab']:not([aria-selected='true']):hover { /* 非選択のタブのホバースタイル */ }}
また、hidden="until-found"
が非対応のブラウザのJS無効環境のフォールバックのために次のようなスタイルも指定しておきます。このスタイルの意味は前回投稿した記事「タブやアコーディオンの非表示コンテンツにはhidden=“until-found”を使うべし」で詳細に解説しているのでそちらを参照してください。
[role='tabpanel']:target { display: revert;}
非表示のスタイリングはhidden="until-found"
のUAスタイルシートを利用し、Authorオリジンでdisplay:none
を指定しないように気をつけてください。
タブメニューのスタイリングはデザインによって異なるため、他に特筆することはありません。
タブメニューのJS
タブ機能を追加するためのJavaScriptの実装例です。コードにはTypeScriptを使用していますので、TypeScriptを利用していないWeb制作現場で使用する場合はChatGPTなどに依頼してJSファイルに変換してください。
マークアップ側はこのように指定しております。
<div id="tabmenu"> <div role="tablist"> <a role="tab" id="tab1" href="#tabpanel1">Tab1</a> <a role="tab" id="tab2" href="#tabpanel2">Tab2</a> <a role="tab" id="tab3" href="#tabpanel3">Tab3</a> </div> <div role="tabpanel" id="tabpanel1" aria-labelledby="tab1">コンテンツ1</div> <div role="tabpanel" id="tabpanel2" aria-labelledby="tab2" hidden="until-found">コンテンツ2</div> <div role="tabpanel" id="tabpanel3" aria-labelledby="tab3" hidden="until-found">コンテンツ3</div></div>
<script> import initializeTabs from '@/scripts/initializeTabs.ts'
document.addEventListener('astro:page-load', () => { const target = document.getElementById('tabmenu')
if (target) { initializeTabs(target) } })</script>
JS無効環境を意識してtabindex
属性とJSでの操作が前提のaria-selected
属性、それに付随するaria-controls
属性はJSで初期化時に付与するようにします。
initializeTabs関数
const initializeTabs = (root: HTMLElement | null, firstView = 1): void => { if (!root) { console.error('initializeTabs: Root element is not found.') return }
const tablist = root.querySelector('[role="tablist"]') as HTMLElement const tabs = root.querySelectorAll('[role="tab"]') as NodeListOf<HTMLAnchorElement> const tabpanels = root.querySelectorAll('[role="tabpanel"]') as NodeListOf<HTMLElement>
if (!tablist || tabs.length === 0 || tabpanels.length === 0) { console.error('initializeTabs: Required elements for tabs are missing or invalid.') return }
const initialIndex = Math.max(0, firstView - 1)
setTabAttributes(tabs, tabpanels) activateTab(tabs, tabpanels, initialIndex)
tabs.forEach((tab, index) => { tab.addEventListener('click', (event) => handleClick(event, tabs, tabpanels, index), false) tab.addEventListener('keyup', (event) => handleKeyNavigation(event, tablist, tabs, tabpanels, index), false) })
tabpanels.forEach((panel) => { panel.addEventListener('beforematch', (event) => handleBeforeMatch(event, tabs, tabpanels), true) })}
initializeTabs
関数は、タブ機能を初期化するための関数です。引数として、タブ機能を適用するルート要素(root
)と、初期表示するタブのインデックス(firstView
)を受け取るようにします。
関数内では、まずルート要素内からタブリスト(role="tablist"
)、タブ(role="tab"
)、タブパネル(role="tabpanel"
)を取得します。これらの要素が存在しない場合は、初期化処理を中断します。
次に、setTabAttributes
関数を呼び出して、タブに必要な属性を設定し、activateTab
関数を呼び出して、初期表示するタブをアクティブにします。
その後、各タブに対してクリックイベントとキーボードイベントのリスナーを登録します。クリックイベントはhandleClick
関数で、キーボードイベントはhandleKeyNavigation
関数で処理されます。
さらに、各タブパネルに対してbeforematch
イベントのリスナーを登録します。これは、ページ内検索時にタブパネルが表示される(hidden="until-found"
が取り除かれる)直前に発生するイベントで、handleBeforeMatch
関数で処理されます。
setTabAttributes関数
const setTabAttributes = (tabs: NodeListOf<HTMLAnchorElement>, tabpanels: NodeListOf<HTMLElement>): void => { tabs.forEach((tab, index) => { tab.setAttribute('aria-selected', 'false') tab.setAttribute('aria-controls', tabpanels[index].id) tab.setAttribute('tabindex', '-1') })}
setTabAttributes
関数は、タブに必要な属性を設定するための関数です。
それぞれのタブにtabindex="-1"
、aria-selected="false"
、対応するタブパネルのid
を指定したaria-controls
を付与します。
初期状態ではすべてのタブが非選択状態になります。
activateTab関数
const activateTab = (tabs: NodeListOf<HTMLAnchorElement>, tabpanels: NodeListOf<HTMLElement>, index: number): void => { tabs.forEach((tab, i) => { const isSelected = i === index tab.setAttribute('aria-selected', String(isSelected)) tab.setAttribute('tabindex', isSelected ? '0' : '-1') })
tabpanels.forEach((tabpanel, i) => { if (i !== index) { tabpanel.setAttribute('hidden', 'until-found') tabpanel.removeAttribute('tabindex') } else { tabpanel.removeAttribute('hidden') tabpanel.setAttribute('tabindex', '0') } })}
activateTab
関数は、アクティブなタブを切り替えるための関数です。引数として、タブのNodeList(tabs
)、タブパネルのNodeList(tabpanels
)、アクティブにするタブのインデックス(index
)を受け取ります。
関数内では、まず各タブに対して、aria-selected
属性とtabindex
属性を更新します。アクティブなタブはaria-selected
属性がtrue
に、tabindex
属性が0
になります。非アクティブなタブはaria-selected
属性がfalse
に、tabindex
属性が-1
になります。
次に、各タブパネルに対して、hidden
属性とtabindex
属性を更新します。非アクティブなタブパネルはhidden
属性がuntil-found
に設定され、tabindex
属性が削除されます。アクティブなタブパネルはhidden
属性が削除され、tabindex
属性が0
に設定されます。
handleClick関数
const handleClick = (event: MouseEvent, tabs: NodeListOf<HTMLAnchorElement>, tabpanels: NodeListOf<HTMLElement>, index: number): void => { event.preventDefault() activateTab(tabs, tabpanels, index)}
handleClick
関数は、タブのクリックイベントを処理するための関数です。
event.preventDefault()
でイベントのデフォルトの動作をキャンセルし、activateTab
関数を呼び出してクリックされたタブをアクティブにします。
handleKeyNavigation関数
const handleKeyNavigation = ( event: KeyboardEvent, tablist: HTMLElement, tabs: NodeListOf<HTMLAnchorElement>, tabpanels: NodeListOf<HTMLElement>, currentIndex: number,): void => { const orientation = tablist.getAttribute('aria-orientation') || 'horizontal'
const keyActions: Record<string, () => number> = { [orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft']: () => currentIndex - 1 >= 0 ? currentIndex - 1 : tabs.length - 1, [orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight']: () => (currentIndex + 1) % tabs.length, Home: () => 0, End: () => tabs.length - 1, }
const action = keyActions[event.key]
if (action) { event.preventDefault() const newIndex = action() tabs[newIndex].focus() activateTab(tabs, tabpanels, newIndex) }}
handleKeyNavigation
関数は、タブのキーボードナビゲーションを処理するための関数です。タブリスト要素のaria-orientation
属性を取得し、キーボードの矢印キーやHomeキー、Endキーに応じてアクティブなタブを切り替えます。
キー | 操作 |
---|---|
左矢印キー | 【aria-orientation が未設定もしくはhorizontal に設定されている場合】フォーカスを前のタブに移動します。最初のタブにフォーカスがある場合は、最後のタブに移動します。 |
右矢印キー | 【aria-orientation が未設定もしくはhorizontal に設定されている場合】フォーカスを次のタブに移動します。最後のタブにフォーカスがある場合は、最初のタブに移動します。 |
上矢印キー | 【aria-orientation がvertical に設定されている場合】フォーカスを前のタブに移動します。最初のタブにフォーカスがある場合は、最後のタブに移動します。 |
下矢印キー | 【aria-orientation がvertical に設定されている場合】フォーカスを次のタブに移動します。最後のタブにフォーカスがある場合は、最初のタブに移動します。 |
Homeキー | フォーカスを最初のタブに移動します。 |
Endキー | フォーカスを最後のタブに移動します。 |
関数内では、まずkeyActions
オブジェクトを定義します。このオブジェクトは、キーと対応するアクションを定義しています。アクションは、次のタブまたは前のタブのインデックスを計算する関数です。
イベントのキーがkeyActions
オブジェクトに定義されている場合、イベントのデフォルトの動作をキャンセルし、対応するアクションを実行します。アクションにより計算された新しいタブのインデックスを使って、activateTab
関数を呼び出し、タブを切り替えます。
handleBeforeMatch関数
const handleBeforeMatch = (event: Event, tabs: NodeListOf<HTMLAnchorElement>, tabpanels: NodeListOf<HTMLElement>): void => { const panel = event.currentTarget as HTMLElement const tabIndex = [...tabpanels].indexOf(panel)
if (tabIndex !== -1) { activateTab(tabs, tabpanels, tabIndex) }}
handleBeforeMatch
関数は、タブパネルが表示される直前に発生するbeforematch
イベントを処理するための関数です。
ページ内検索でhidden="until-found"
内のコンテンツがヒットした際にタブを切り替えます。
イベントの発生元のタブパネルを取得し、そのインデックスを計算します。インデックスが有効な範囲内にある場合、activateTab
関数を呼び出して対応するタブをアクティブにします。
beforematch
イベントの詳細についてはMDNのドキュメントを参考にしてください。