React の手習いに開閉式のいわゆるアコーディオンUIを実装してみましたので備忘録。

Javascript, ES2016, React, WAI-ARIA とも確信を持ってるわけではなく、いずれも調べながらの実装なので、マサカリツッコミやアドバイス歓迎です。

要件

アコーディオンUIは、コンテンツの表示トリガー(アコーディオンヘッダー)とコンテンツ(アコーディオンパネル)がグルーピングされたものが(主に縦方向に)並んでいるものを指していることが多いと思いますが、とりあえず次の要件で実装しました。

  • アコーディオンヘッダーをクリックでアコーディオンパネルの開閉を切り替え
  • アコーディオンパネルの開閉の初期状態を設定可能
  • すべて開く / すべて閉じる ボタンの実装(ボタンを表示させるかは選択可能)
  • フォーカスマネジメントする(隠れているアコーディオンパネル内の要素はフォーカスされない)
  • キーボードで操作可能( Tab は DOM順にフォーカス移動 / 矢印キーでのアコーディオンヘッダー間を移動)

デモンストレーション

codepen

See the Pen Collapsible(Accordion) UI with React & WAI-ARIA role="tablist" by Soichi Masuda (@masuP9) on CodePen.

別画面でデモを表示する

React

React のコンポーネント的には <AccordionList>とその子要素である <Accordion>の二つにしました。開閉の状態は、すべて閉じるボタン、すべて開くボタンのため、<AccordionList>stateBoolを配列で保持し、<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"を採用するのが良いのかなと思います。またあるいは、detailsummaryを使用することも考えられます。

一応、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"でマークアップすると、タブの個数と今のタブが何個目かを読み上げてくれます。(対応しているものの場合)

Mac VoiceOverで読み上げた際のダイアログ
Accordion1、選択された項目:字間広く、タブ、1/3

aria-expanded="true"を字間広くって読み上げてるのは気のせいですかね...

タブの並びが垂直方向になったようなアコーディオンUIの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-expandedaria-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 に渡します。ざっと当該箇所のみのコードを次に、

すべて開くボタンのJSX実装
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"を付与しフォーカス可能にした上で、SpaceEnterで開閉の切り替え
  • 閉じたアコーディオンパネル内の要素にはフォーカスさせない
  • アコーディオンヘッダーにフォーカスが当たっている場合に矢印キーの上左で前のアコーディオンヘッダーに、下右で次のアコーディオンヘッダーにフォーカスを当て、最初と最後のアコーディオンヘッダーの場合はフォーカスを移動させない

今回は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-ステートの更新に関しては、コンポーネントが持つstatepropsとリンクさせる形で実装すれば簡単に実装することができると感じました。

roleはコンテキストに合わせて適切なものを選択し(これが難しいわけで今回も時間を食ったのですが...)、aria-プロパティも参照関係を基本的にpropsで渡したり参照すれば問題なく実装できるかと思います。

逆に大変だなあと感じたのはフォーカス管理で、これは自分の React/JavaScript スキルの低さによるものなのか分かりませんが、コンポーネントをまたぐ場合や、this.props.children内のフォーカス可能な要素のtabindexを操作するのはまだ解を見つけられてなかったりするので力をつけていきたい所存です。

やはりコンポーネント単体の実装ではなくて、アプリケーションをイチから実装してみないとなあ。

参考