WAI-ARIA 対応のアクセシブルなタブ UI を React で実装する

タブ UI のアクセシビリティ対応

この記事は、下記の点に留意してご覧いただきたい。

  • タブ UI におけるアクセシビリティ対応(主に WAI-ARIA)の参考実装であること
  • React コンポーネントとしての機能性は二の次のサンプルであること
  • アクセシビリティ実装についてのツッコミは歓迎であること

ひさびさにPCキーボード的なアクセシビリティ対応が必要そうな性質のサービスを開発することになったので、コンポーネント単位でのアクセシビリティ関連実装を進めている。

React 実装

今回はタブ UI を題材に、React コンポーネントを実装した。

行うべき操作を見通しよくするために、タブ UI を <Tabs> コンポーネント1つで実装した。厳密には <TabList><TabPanel> などの要素を分解してコンポーネント化したほうが良いだろうが、今回は簡易実装としている。

キーボード操作への対応

  • タブキーを押すと「選択中のタブ」→「タブコンテンツ」の順にフォーカスが移る
  • 「選択中のタブ」の上で「→」または「←」の矢印キーを押すとタブが切り替わる

動作サンプル

下の CodePen 埋め込みが動作サンプルだ。

See the Pen react-a11y-tabs by Ayumu Sato (@ahomu) on CodePen.

その他の情報提供

コンポーネント内の要素についている [aria-*] の属性を適宜更新することによって、各要素の状態についてユーザーエージェントに情報を提供している。

  • aria-selected による選択中であるかどうかのフラグ
  • aria-controls による何の要素をコントロールしているかの識別子
  • aria-hidden による表示中であるかどうかのフラグ

.is-active.is-hidden といったスタイル操作用の class と同様のタイミングで操作すべき「状態」を示す属性が多い。.is-* と併用するか、属性セレクタによるスタイル指定に抵抗がなければ [aria-*] だけで運用してもよいだろう。

Tabs コンポーネントの実装

コンポーネント本体の実装コードに軽くコメントしたものを以下に貼った。CodePen が Babel に対応していたのでいつもどおり ECMAScript 6 で書けて満足している。

createClass スタイルの React に親しみが薄いので・・・

const KEYCODE_LEFT  = 37;
const KEYCODE_RIGHT = 39;

class Tabs extends React.Component {

  constructor() {
    super();
    this.state = {
      index: 0
    };
  }

  updateIndex(i, fn) {
    // index が更新されて DOM が更新されたのちフォーカスをあてる
    this.setState({index: i}, () => {
      React.findDOMNode(this.refs['tab' + i]).focus();
    });
  }

  onClickTab(i) {
    this.updateIndex(i);
  }

  onMoveTab(e) {
    // ここでは index の更新に専念すればいい
    let curtIndex = this.state.index;

    switch(e.keyCode) {
      case KEYCODE_LEFT:
        if (curtIndex !== 0) {
          this.updateIndex(curtIndex - 1);
        }
        break;
      case KEYCODE_RIGHT:
        if (curtIndex !== this.props.children.length - 1) {
          this.updateIndex(curtIndex + 1);
        }
        break;
      default:
        break;
    }
  }


  render() {
    let curtIndex = this.state.index;
    
    // タブ部分を生成する、ラベルは child.props.label を使う
    // aria-* 系の属性は、React らしく index を使った条件式を宣言するだけでよい
    // あとからフォーカスをあてるのに ref を仕込んでおいた方がラクだった
    let tabs = this.props.children.map((child, i) => {
      return (
        <li role="presentation" key={i}>
          <button role="tab"
                  ref={'tab' + i}
                  tabIndex={curtIndex === i ? '0' : '-1'}
                  aria-selected={curtIndex === i ? 'true' : 'false'}
                  aria-controls={child.props.id}
                  onKeyUp={this.onMoveTab.bind(this)}
                  onClick={this.onClickTab.bind(this, i)}>
            {child.props.label}
          </button>
        </li>
      );
    });

    // パネル部分も同様に生成する
    let panels = this.props.children.map((child, i) => {
      return (
        <div role="tabpanel"
             key={i}
             id={child.props.id}
             aria-hidden={curtIndex === i ? 'false' : 'true'}>
          {child}
        </div>
      );
    });

    return (
      <div>
        <ul role="tablist">
          {tabs}
        </ul>
        {panels}
      </div>
    );
  }
}

感想など

今回 React で書いてみたところ、JSX 上に tabIndex={curtIndex === i ? '0' : '-1'} のような条件を書き込んでおけば、state なり props を更新するだけで現在の状態を気にせず WAI-ARIA の属性が書き換わるのでラクだった。

コンポーネントの細かい状態は管理しなくても、パラメータさえ更新すれば自動でアトミックな更新がかかる React の良さがあらわれていたように思う。

React と属性操作の相性が良いのでオススメできる

これまで WAI-ARIA の対応で面倒だったのは、ことあるごとに要素の状態を取得したり保持する部分や、頻繁な DOM API へのアクセスが強いられる部分だ。その点、JSX でもコード量は確かに増えるが、複雑さはないのでスパゲティ化の原因にはなりそうにない。

jQuery で同じことをやれと言われたら少々暗い気持ちになるが、React なら相性が良いと思うので簡単な UI から試しにやってみると良いだろう。

参考