React の手習いに開閉式のいわゆるアコーディオンUIを実装してみましたので備忘録。
Javascript, ES2016, React, WAI-ARIA
とも確信を持ってるわけではなく、いずれも調べながらの実装なので、マサカリツッコミやアドバイス歓迎です。
要件
アコーディオンUIは、コンテンツの表示トリガー(アコーディオンヘッダー)とコンテンツ(アコーディオンパネル)がグルーピングされたものが(主に縦方向に)並んでいるものを指していることが多いと思いますが、とりあえず次の要件で実装しました。
- アコーディオンヘッダーをクリックでアコーディオンパネルの開閉を切り替え
- アコーディオンパネルの開閉の初期状態を設定可能
- すべて開く / すべて閉じる ボタンの実装(ボタンを表示させるかは選択可能)
- フォーカスマネジメントする(隠れているアコーディオンパネル内の要素はフォーカスされない)
- キーボードで操作可能( Tab は DOM順にフォーカス移動 / 矢印キーでのアコーディオンヘッダー間を移動)
デモンストレーション
See the Pen Collapsible(Accordion) UI with React & WAI-ARIA role="tablist" by Soichi Masuda (@masuP9) on CodePen.
React
React のコンポーネント的には
<AccordionList>
とその子要素である
<Accordion>
の二つにしました。開閉の状態は、すべて閉じるボタン、すべて開くボタンのため、<AccordionList>
のstate
にBool
を配列で保持し、<Accordion>
にprops
として渡していきます。
こうすると、<Accordion>
単体で動作しなくなるので本当は良くない気がしますが、<Accordion>
のstate
として開閉状態を持たせると、すべて閉じるボタンの動作時にstate
を外から変えることになるので避けました。ただ他にいいやり方がありそうな気もします...
WAI-ARIA
role
role
は、すべて閉じる/開くボタンをまとめる
role="toolbar"
と、WAI-ARIA
Authoring Practices 1.1 -
アコーディオンを参考に、アコーディオンヘッダーをrole="tab"
、アコーディオンパネルをrole="tabpanel"
、それらをまとめるラッパーをrole="tablist"
としました。
なおコンポーネントがページやアプリ(コンテキスト)の中でどのような役割を持っているで、採用するrole
は変わってくると思います。
たとえば、よくある質問やQ&Aといったコンテンツでよく見られる見出し
+
詳細の集合に対して、一覧性を高めるために開閉式のUIを採用している場合は、role="tablist"
でマークアップすると、見出しのrole
を上書きする必要が出てきてしまうため、暗黙の
role="heading"
を優先し、そうでない場合は、role="tablist"
を採用するのが良いのかなと思います。またあるいは、detail
、summary
を使用することも考えられます。
一応、ARIA in HTML - Document
conformance requirements for use of
ARIA attributes in HTML
では、h1
から
h6
要素は、role="tab"
を使用してもよいとしてますので、<h1 role="tab">
は問題ないのですが、個人的にはすごく違和感があります。
role="heading"
とrole="tablist"
の違いは、ことスクリーンリーダーに限った話では、見出しでマークアップするといわゆる見出しジャンプが使えますし、role="tablist"
でマークアップすると、タブの個数と今のタブが何個目かを読み上げてくれます。(対応しているものの場合)

aria-expanded="true"
を字間広くって読み上げてるのは気のせいですかね...
role
の実装例
<ul role="tablist" aria-multiselectable="true">
<li role="presentation">
<a role="tab" aria-controls="accordion__panel-1" aria-expanded="true" aria-selected="true">accordion__header</a>
<div id="accordion__panel-1" aria-hidden="false">
Content...
</div>
</li>
<li role="presentation">
<a role="tab" aria-controls="accordion__panel-2" aria-expanded="false">accordion__header</a>
<div id="accordion__panel-2">
Content...
</div>
</li>
</ul>
aria-*
ステート
ステートはアコーディオンヘッダーであるa[role="tab"]
にaria-expanded
とaria-selected
、アコーディオンパネル部分のaria-hidden
の二つの状態があります。
開閉の状態は、<AccordionList>
から<Accordion>
にBoolで与えられるので、それをそのままaria-expanded={this.props.expanded}
と参照するのみでステートが切り替わります。(aria-hidden
は{!this.props.expanded}
)
<Accordion>
のJSX実装<li className={this.props.className + ' ' + classBlockName} role="presentation">
<a className={classBlockName + "__header"}
ref={classBlockName + "__header"}
onClick={this.handleExpandItem.bind(this, this.props.dataNum)}
onKeyDown={this.handleKeyExpandItem.bind(this, this.props.dataNum)}
role="tab"
aria-expanded={this.props.expanded}
aria-selected={this.props.expanded}
aria-controls={this.props.id}
tabIndex="0">{this.props.label}
</a>
<div className={classBlockName + "__panel"}
id={this.props.id}
aria-hidden={!this.props.expanded}
style={this.props.expanded ? viewStyle : null}>
{this.props.children}
</div>
</li>
aria-*
プロパティ
プロパティで使用しているのは、aria-controls
のみで、aria-controls
には制御する要素のIDを持たせるだけですが、すべて閉じる/開くボタンは対応する要素が複数あるため、ID参照リスト(空白区切り)の形で表記する必要があります。
よって、<AccordionList>
は子<Accordion>
のid
の値を集めて、button
に渡します。ざっと当該箇所のみのコードを次に、
const buttonControlItems = this.props.children.map((child) => {
return child.props.id;
}).join(' ');
const buttons = <div className={this.props.className + '__buttons'} role="toolbar">
<button type="button"
className={this.props.className + '__button -expand'}
onClick={this.expandsAllItems.bind(this)}
aria-controls={buttonControlItems}>All Expand
</button>
</div>;
return (
{buttons}
);
フォーカス管理とキーボードによる操作
フォーカス管理とキーボードによる操作では、次の実装をしました。
-
href
属性の無いa
要素をrole="tab"
としているので、それぞれにtabindex="0"
を付与しフォーカス可能にした上で、SpaceとEnterで開閉の切り替え - 閉じたアコーディオンパネル内の要素にはフォーカスさせない
- アコーディオンヘッダーにフォーカスが当たっている場合に矢印キーの上左で前のアコーディオンヘッダーに、下右で次のアコーディオンヘッダーにフォーカスを当て、最初と最後のアコーディオンヘッダーの場合はフォーカスを移動させない
今回はTabでもアコーディオンヘッダー間の移動を可能にしているので、tabindex
は固定で問題なし、キーボードでの操作は、onKeyDown
イベントで、キーごとに操作を振り分ける形で実装しました。
閉じたアコーディオンパネル内の要素にフォーカスをさせない、という件はコンポーネントのthis.props.children
内のフォーカス可能な要素を抽出して全てのtabindex
を操作するのが考えただけで大変そうなので、CSSでガサッとvisibility: hidden;
にして逃げて?しまいました。
visibility: hidden;
でフォーカスをあてさせないCSS
.accordion .accordion__panel[aria-hidden="true"] * {
visibility: hidden;
}
感想
React自体は先にVue.jsを触っていたおかげで思ったよりすんなり入れましたが、まだまだ奥は深そうっていうか深いですね。また単一のUIのみのサンプル実装なのでなんともですが、マークアッパーとしてはコンポーネントの粒度をどうしてもCSSのコンポーネントの単位で考えてしまうのでそこは機能的な考え方に切り替えないとだめそう。
ReactからWAI-ARIAの属性を操作するという意味では、非常に参考にさせていただいた「WAI-ARIA 対応のアクセシブルなタブ
UI を React
で実装する」でも述べられていたとおり、aria-
ステートの更新に関しては、コンポーネントが持つstate
やprops
とリンクさせる形で実装すれば簡単に実装することができると感じました。
role
はコンテキストに合わせて適切なものを選択し(これが難しいわけで今回も時間を食ったのですが...)、aria-
プロパティも参照関係を基本的にprops
で渡したり参照すれば問題なく実装できるかと思います。
逆に大変だなあと感じたのはフォーカス管理で、これは自分の
React/JavaScript
スキルの低さによるものなのか分かりませんが、コンポーネントをまたぐ場合や、this.props.children
内のフォーカス可能な要素のtabindex
を操作するのはまだ解を見つけられてなかったりするので力をつけていきたい所存です。
やはりコンポーネント単体の実装ではなくて、アプリケーションをイチから実装してみないとなあ。