eureka

【Nuxt】WAI-ARIAに対応してアクセシビリティに強いタブUIをつくる

0

WAI-ARIAちゃんと意識しておるかの?

おはこんばんにちは(言ってみたかった)。

タブUIを作成する際にコンテンツをStoreで管理して表示させたら早いなーと思ってたんですが、そういえばWAI-ARIAあったなってなってロジックが思いっきり変わりました。笑

管理画面等ならまだしも、コーポレートサイトで使用するような場合にはアクセシビリティにもしっかり対応したいところですよね。誰にでも優しいサイトを作りたいですから!

WAI-ARIAの前置きが長いので実装だけ読みたい人は目次から飛ばしてください

環境

  • Nuxt 2.14.0

そもそもWAI-ARIAとは

『わいありあ』って読むらしいです。

WAI-ARIA は W3C によって定められた仕様で、要素に適用できる追加の意味論を提供する一連の HTML 属性を定義しており、それが欠けているどのような場所でもアクセシビリティを向上させます。

WAI-ARIA の導入 – MDN

基本的にHTMLの補完としてWAI-ARIAがあり、複雑な要素の使い方をする場合にはアクセシビリティ向上のために追加で意味付けしてねってことですね。
こちらの記事にWAI-ARIAを使用するルールが簡潔にまとめられていてよかったのでご紹介します。
WAI-ARIA 実装の5つのルール

WAI-ARIAに対応させようと思ったらrole属性とaria属性の2種類になるのですが、機能としては以下の3つに分類されます。

ロール(Role)

role属性を付与したタグに役割や意味を与えることができる。レイアウトに関連するランドマークロールと、ランドマークロールより小さい構造を表す文書構造ロール、ユーザーインターフェースを表すウィジェットロールに分けられる。

そのうちランドマークロールには、role="navigation"などが有名かと思いますが、<nav><header>などのHTML5のタグにはrole属性が暗黙的に含まれているので、きちんとタグを使用していれば使用しなくても問題はないとされています。

難しいことはこちらに書いてあります😇
2.1 WAI-ARIAロール – Accessible Rich Internet Applications (WAI-ARIA) 1.1 日本語訳

プロパティ(Property)

要素の性質や特性を定義する。

ステート(State)

要素の現在の状態を示す。性質・特性(property)を示す属性、ロールとの組み合わせで、ユーザーに『何が、いつ、どう』変化したのかを通知できる。

タブUIを作成する

前置きが長くなりましたが、早速。
今回は以下の記事を参考に、Nuxtでタブとタブパネルのコンポーネントを作成してview側からタブをコントロールしていきます。
WAI-ARIA対応のタブ型UIの作り方(Vue.js編)

WAI-ARIA属性使用のイメージ

何の属性を指定すればいいかすぐには覚えられないので自分への覚書きとして今回のタブUIで使用するWAI-ARIAの属性を画像にしときました。

実装するタブUIの部品とWAI-ARIAの属性
タブUIの各部品の名称と使用するWAI-ARIA属性

Tabコンポーネントを作成する

components/Tab.vueに下記のように記述しました。
propsで受け取るデータはビュー側で定義しているので、比較して確認してください。

個人的にはタブはリスト表示させないタイプなのでbuttonタグのみをv-forで回しています。
$emitに関しては後ほど。

CSSに関しては以下に賛同してaria属性をセレクターとして使用しました。

CSSの実装はCodeGridの記事「WAI-ARIAを活用したフロントエンド実装」で紹介されている「aria属性をCSSセレクターとして利用する」「独自に名前を付けるくらいなら、意味的に合致するaria属性を利用して、アクセシビリティーを確保しましょう」の提案をアイデアとしています。

WAI-ARIA対応のタブ型UIの作り方(Vue.js編)
<template>
  <div role="tablist">
    <button
      v-for="tab in tabData"
      :key="tab.id"
      type="button"
      role="tab"
      :aria-controls="tab.id"
      :aria-selected="tabState === tab.id"
      @click="handleTabChange($event)"
      v-text="tab.title"
    />
  </div>
</template>

<script>
export default {
  name: 'Tab',

  props: {
    tabData: {
      type: Array,
      required: true,
    },

    tabState: {
      type: String,
      required: true,
    },
  },

  methods: {
    handleTabChange(event) {
      const element = event.currentTarget
      const tabState = element.getAttribute('aria-controls')

      this.$emit('onTabChange', tabState)
    },
  },
}
</script>

<style lang="scss" scoped>
[aria-selected="true"] {
  background-color: royalblue;
  color: white;
}
</style>

TabPanelコンポーネントを作成する

続いてcomponents/TabPanel.vueを作成し、タブパネル用のパーツを作成します。

<template>
  <div>
    <div
      v-for="panel in panelData"
      :id="panel.id"
      :key="panel.id"
      role="tabpanel"
      :aria-labelledby="panel.id"
      :aria-hidden="tabState !== panel.id"
    >
      <p v-text="panel.content" />
    </div>
  </div>
</template>

<script>
export default {
  name: 'TabPanel',

  props: {
    panelData: {
      type: Array,
      required: true,
    },

    tabState: {
      type: String,
      required: true,
    },
  },
}
</script>

<style lang="scss">
[aria-hidden='true'] {
  display: none;
}

[aria-hidden='false'] {
  display: block;
}
</style>

ビュー側のコード

TabコンポーネントとTabPanelコンポーネントを内包するpages/index.vueを作成します。下記のように記述しました。

<template>
  <div>
    <Tab
      :tab-data="tabData"
      :tab-state="tabState"
      @onTabChange="handleTabChange"
    />
    <TabPanel :panel-data="tabData" :tab-state="tabState" />
  </div>
</template>

<script>
const tabData = [
  {
    id: 'tab1',
    title: 'タブ1',
    content: 'コンテンツ1',
  },
  {
    id: 'tab2',
    title: 'タブ2',
    content: 'コンテンツ2',
  },
  {
    id: 'tab3',
    title: 'タブ3',
    content: 'コンテンツ3',
  },
]

export default {
  data() {
    return {
      tabState: '',
      tabData,
    }
  },

  created() {
    this.tabState = this.tabData[0].id
  },

  methods: {
    handleTabChange(tabState) {
      this.tabState = tabState
    },
  },
}
</script>

tabStateというデータで現在のアクティブなタブIDを管理し、TabとTabPanelにpropsとして渡すことで各パーツの表示をコントロールています。

$emitを使用すると子コンポーネントから親コンポーネントへデータを渡すことができるので、Tabコンポーネントで発生するクリックイベントからaria-controls(連動しているタブパネルのid)の値を拾ってビュー側(index.vue)に送ります。

ビュー側ではTabコンポーネントから受け取ったデータでtabStateを更新して、propsを通してTabとTabPanelコンポーネントに値を渡すことでタブが切り替わるようにCSSで調整しています。

なぜCSSでタブを切り替えるのか

こちらの実装ではTabPanelコンポーネントに表示するデータを全て渡して、CSSでアクティブなタブに連動した内容の表示を制御しているのですが、パフォーマンスを考えると最初から切り替える内容だけビューからTabPanelコンポーネントに送れば良くない?って思うかもしれません。(私のことですが)

しかし、その実装ではaria-controlsがa11y のチェッカーでエラーになるようです。

aria-controls に関わらず、id を参照するものは要素があらかじめ存在する必要がある。Accessibility Tree が DOM を元に生成する都合からだと思われる。

タブ切り替えを実装する時の注意点 – grgr-dkrkのブログ

v-ifなどでタブを切り替えてしまうとDOMからも消えてエラーになるので、一度タブパネルのデータを全てレンダリングしてからCSSで非表示にするということをしています。

最後に

タブパネル内のデータがめちゃめちゃ重かったりするとレンダリングのパフォーマンスにも影響が大きいと思うので、本当に重かったらデータをできるだけ分割させるとか、ページとして分割させるとかの工夫が必要になるかも?と思っています。

あとはタブにpropsで渡すデータも絞ったり、プロジェクトによってはリファクタしても良いと思います。

参考にしたサイト

0