Vue.jsでWebの多様なユーザー/利用シーンに対応していくための公開素振り

f:id:aiyoneda:20191213172327p:plain

この記事はBASE Advent Calendar 2019の15日目の記事です。

こんにちは。フロントエンドグループの加藤です。 私達は、「Payment to the People,Power to the People.」というミッションを掲げ、日々サービスづくりを頑張っています。

Peopleとは誰か

このミッションにある、Peopleとは誰のことを指すのでしょうか?

自分の周りの環境を想像しても、実に多様な人がいることがわかります。 また、日々ショップオーナーさんや購入者さんからいただく様々なお問い合わせの内容を見ていると、ほんとに様々な背景を持った方々に使っていただいているんだなと思います。

Webフロントエンド開発者としては、自分の力で出来ることがあれば、出来る限り多様な使われ方に対応できるプロダクトにしていきたいという思いがあります。

何を指針とするか

では、まず何をどうすればいいのでしょうか。よくわかりません。

調べると、どうやらWeb技術の標準化を行う非営利団体であるW3C(World Wide Web Consortium)が勧告しているガイドラインが存在するようです。 Web Content Accessibility Guidelines (WCAG) 2.0

また、WCAG2.0に関しては実際に対応する時に参考にできる解説書もありました。どちらも日本語化されています。大変ありがたいです。 WCAG 2.0 解説書

細かく言うと、この項目をどれだけ対応するかによってレベルA~AAAなどのレベル付けがあるようです。今回は絶対どのレベルを厳守するんだ!というよりも、自分の中で実装時に意識すべきことを掴みたい、まずは慣れたい、といった動機で始めているので、まずはAの中でも対応できそうなものからやってみたいと思います。

練習してみよう

ということで、実際に弊社でも利用しているフレームワーク、Vue.jsを使って、かんたんなウェブページをよりアクセシブルにしていく素振りをしてみます。

まずは何も考えずに作っていく

作るものはなんでもよいので、「自由に投稿できる動物ずかん」をイメージして作ります。一覧画面と詳細ページ、そしてモーダルで開いて入力する画面があるとしましょう。

できました。 CodeSandboxのリンク

特筆すべき点はないですが、

  • モーダルとボタンをそれぞれ共通のコンポーネントとして切り出した
  • vue-routerを利用してクライアントで一覧と詳細画面をそれぞれルーティングさせている

というところで、非常に簡単ではありますが実際のフロントエンド開発でよくあるシーンを再現してみました。(今回はVue.jsやその他のライブラリの詳しい説明に関しては省きます)

課題を発見していこう

とにかく動くものを作ったのですが、そもそも課題が何なのかわかっていません。

今回はWCAG2.0をバイブルとして進めていくので、一つ一つ目を通して、これは守れてないなと思ったものを地道にクリアしていくことにしましょう。

原則 1: 知覚可能 - 情報及びユーザインタフェース コンポーネントは、利用者が知覚できる方法で利用者に提示可能でなければならない。

1.1.1 非テキストコンテンツ: 利用者に提示されるすべての 非テキストコンテンツ には、同等の目的を果たす テキストによる代替 が提供されている。

ただし、次の場合は除く(!)

コントロール、入力: 非テキストコンテンツが、コントロール又は利用者の入力を受け付けるものであるとき、その目的を説明する 名前 (name) を提供している。

これは、ひとまず作ったボタンコンポーネントに問題があります。

<template>
  <div @click="$emit('click')" class="button">
    <slot></slot>
  </div>
</template>

divですね。コントロール又は利用者の入力を受け付けるものだと全く伝わりません。初歩的ですが、気を抜くと似たようなことはよくやってしまいます。

この項目にリンクされている解説書の項目では、

アクセシブルなウェブコンテンツ技術の標準コントロールを使用する場合、このプロセスは簡単である。ユーザインタフェース要素が仕様に準じて使用される場合、この条件に条項は満たされる。

とされており、セマンティックなマークアップを守れば特別な工夫をせずとも条件を満たすことができるようです。以下のようにしてみました。

<template>
  <button @click="$emit('click')" class="button">
    <slot></slot>
  </button>
</template>

よさそう。しかしこれもまだ問題があります。

これはアクセシビリティの問題ではなく、このボタンというコンポーネントは、機能としてのボタンを切り出したいのではなく、単なるプレゼンテーションとしてのボタンぽい見た目をただ切り出したいのです。ボタンの見た目を提供するのに、buttonタグとしてしか使えないと、使われる文脈によっては正しくないマークアップを強制してしまう可能性があります。

ここは、デフォルトはbuttonタグで、必要に応じてprops経由でタグを指定出来るようにしてみましょう。こういったケース(ルートエレメントを動的に変えたい)は、jsxで書くことで実現できます。詳しくは、弊社松原のVue.js+JSX基本文法最速入門という記事を見ていただくとよく理解できるかと思います。

export default {
  props: {
    tag: {
      type: String,
      default: "button"
    }
  },
  render(h, context) {
    const tag = this.$props.tag;
    return (
      <tag {...this.$attrs} class="button" onClick={() => this.$emit("click")}>
        {this.$slots.default}
      </tag>
    );
  }
};

こうすることで、以下のようにaタグをボタンにしたい場合でも対応することが可能になりました。

<custom-button tag="a" href="/hoge" />

また、実はもう一つ課題があります(インタラクティブな要素の実装は本当に大変ですね)。

よくあるケースなのですが、マウスでクリックしたあと、その要素がフォーカスされるので、フォーカスインジケータが表示されるのですが、これをポインティングデバイスによるフォーカスでは出さないようにしたいのです。

しかし、これを素朴に.button:focus{outline: none;}としてしまうと、ポインティングデバイス以外の入力によってフォーカスされた場合、視覚でそのことを伝えることができません。先程の達成基準 4.1.2 の解説にも特に重要なユーザインタフェース コントロールの状態は、フォーカスを持つかどうかである。とありますが、マウス以外の入力では、フォーカスされた状態をユーザーに伝えることは非常に重要そうです。

幸いにも、CSSの*:focus-visible疑似クラスによってこれは達成できます。これは、ユーザーエージェントが要素にフォーカスを明示するべきであるとした場合にのみスタイルを適用することが出来る便利な擬似クラスです。しかしながら、まだこれは草案の段階であり、ほとんどのブラウザで実装されていませんので、今回はpolyfill(focus-visible)を導入し、該当のフォーカス時にのみ要素に適用されるdata属性に対してフォーカスのスタイルを当ててみましょう。

.button:focus {
  outline: none;
}
.button[data-focus-visible-added] {
  outline: 2px solid #000;
}

これでbuttonコンポーネントは一旦大丈夫そうです。

tableタグ

captionをつける

今回、動物の一覧を並べるのにtableタグを使用しました。CSSの表現力が高まるにつれて使う機会が徐々に減ってはいますが、管理画面などで一覧性を担保しながら要素を表に並べるという用途では未だによく使うタグでもあります。

今回は以下の達成方法にある、caption要素を使用するとより何の表なのかがわかりやすいのではないかと思いました。

H39: データテーブルのキャプションとデータテーブルを関連付けるために、caption 要素を使用する | WCAG 2.0 達成方法集

行全体をクリッカブルにする

<tbody>
  <tr :key="animal.id" v-for="animal in animals" @click="onRowClick(animal.id)">
    <td>{{ animal.name }}</td>
    <td>{{ animal.emoji }}</td>
  </tr>
</tbody>

行全体をクリッカブルにして、押されたらその行を詳しく見る/操作できる詳細画面が開く、ようなアプリケーションはよくあると思いますが、今回もそうしてみました。ただ、trのクリックイベントでページ遷移させていて、先ほどのようにセマンティックなマークアップでないがために、押せることもわからないし、キーボードで操作することもできません。困った。

一度、全体をクリッカブルにしたい!というところから一歩引いて、そもそもこれは何ができればいいのかを考えてみます。詳細ページに飛ばしたいんですよね。であれば、aタグでマークアップされるべきです。ただ今回はtableでマークアップしていて、trをaタグで囲うことができません。ただ、cellの中には当然aタグを置くことはできます。という発想から、以下のようにしてみました。

<table class="table">
      <caption>現在登録されている動物の一覧</caption>
      <thead>
        <tr>
          <td>名前</td>
          <td>emoji</td>
          <td class="util-hidden">リンク</td>
        </tr>
      </thead>
      <tbody>
        <tr class="row" :key="animal.id" v-for="animal in animals" @click="onRowClick(animal.id)">
          <td>{{ animal.name }}</td>
          <td>{{ animal.emoji }}</td>
          <td class="util-hidden">
            <router-link :to="{name: 'detail', params: { id: animal.id }}">{{ animal.name }}の詳細を見る</router-link>
          </td>
        </tr>
      </tbody>
    </table>
export default {
  // ...
  methods: {
    onRowClick: function(id) {
      this.$router.push({ name: "detail", params: { id } });
    }
  }
};
.row {
  cursor: pointer;
}
.row:hover {
  background-color: #ddd;
}
.row:focus-within {
  background-color: #ddd;
}
.util-hidden {
  position: absolute !important;
  clip: rect(1px, 1px, 1px, 1px);
}

思い切って、テキストリンクを配置した列を新たに追加しました。ただ、その列を表示上は非表示にします。(.util-hiddenという名前のユーティリティクラスを付与していますが、これはアクセシビリティの対応でよく使われるCSSです。スクリーンリーダーなどの支援技術で利用してもらいたいのですが、視覚上からは非表示にしたほうが都合が良いケースで使われます。)

これにより、リンクにキーボードでフォーカスすること自体は可能になりました。その際に視覚上で行全体のフォーカスを表現するため、focus-within疑似クラスを使用します。これは、内包する要素がフォーカスされていた場合にスタイルを適用することが出来る便利な擬似クラスです(これもfocus-visibleと同じように全てのブラウザで対応しているわけではないので、focus-within-polyfillを利用しています)。

trタグにfocus-withinで内包する要素がフォーカスされている場合にスタイルを適用することにより、キーボードでも現在フォーカスしている行を視認しながら移動することが可能になりました。

最後に、マウスでクリックできることを表現するために、hover擬似クラスでカーソルをpointerに設定し、trがクリックされたら、詳細画面へのルーティングを実行するようにします。

こんな感じでしょうか…?

モーダルダイアログ

モーダルはWCAGを見る前から何かあるだろうな…と思いましたが非常に問題が多そうです。ただ、モーダルをアクセシブルに作る方法はある程度約束事が決まっており、少し前ですがヤフー株式会社の福本さんによるスライドがわかりやすく非常に参考になります。 端的に言えば以下の対応になります。

  • マシンリーダブルなコードにするため、WAI-ARIA属性によりマークアップの情報を増やす
  • キーボード操作に対応する
    • フォーカストラップを実装する
    • escキーで閉じれるようにする
    • 開いた際に最初のインタラクティブな要素にフォーカスを移す
    • 閉じた際に元々フォーカスしていた要素にフォーカスを戻す

まるっと勢いで実装してみます。

マシンリーダブルなコードにするため、WAI-ARIA属性によりマークアップの情報を増やす

<div
    role="dialog"
    aria-modal="true"
    :aria-labelledby="titleId"
    :data-show="`${show}`"
    @click="$emit('cancel')"
    class="wrapper"
  >
</div>

---
props: {
  titleId: String,
  rootId: String
},
methods: {
    onShow: function() {
      this.rootId && document.getElementById(this.rootId).setAttribute('aria-hidden', true);
    },
    onHide: function() {
      this.rootId && document.getElementById(this.rootId).setAttribute('aria-hidden', false);
},

role属性によりダイアログであることと、aria-modal属性により現在のダイアログの下にあるウィンドウは不活性であることを支援技術に伝えます。

また、モーダルダイアログのタイトルとなる要素のidと、本文のコンテンツをラップしている要素のidをprops経由で渡します。前者はaria-labelledbyでモーダルコンテンツのタイトルを伝え、後者はモーダルを開いている際に、裏側のメインコンテンツが非表示になっていることを伝えるために使用します。

開いた際に最初のインタラクティブな要素にフォーカスを移す

今回は、props経由で最初にフォーカスすべき要素のidを受け取るシンプルな作りにしました。

props: {
    //...
    initialFocus: String
  },
methods: {
    onShow: function() {
      this.initialFocus && document.getElementById(this.initialFocus).focus()
    },
//...
----
<modal initialFocus="animal-name" :show="modalShow" @cancel="closeModal">

閉じた際にフォーカスを戻す

data: function() {
    return {
      lastActiveElement: null
    };
  },
  watch: {
    show: function(next) {
      if (next === true) {
        this.onShow();
      } else {
        this.onHide();
      }
    }
  },
  methods: {
    onShow: function() {
      this.lastActiveElement = document.activeElement;
    },
    onHide: function() {
      this.lastActiveElement && this.lastActiveElement.focus();
    },

フォーカストラップを実装する

フォーカストラップとは、特にモーダルな状態においてそのコンテンツ内でフォーカスをループできるようにすることで、コンテンツ内での操作性を向上させるための機能です。

今回は実装についての詳細な説明を省きたいので、vue-focus-lockというライブラリの力を借りました。モーダルのコンテンツを包むだけで上記の機能を実現してくれます。

<template>
  <div :data-show="`${show}`" @click="$emit('cancel')" class="wrapper">
    <div class="contents" @click.stop>
      <focus-lock>
        <slot></slot>
      </focus-lock>
    </div>
  </div>
</template>

escキーで閉じれるようにする

props: {
    escExit: {
      type: Boolean,
      default: true
    }
  },
methods: {
    onShow: function() {
      document.addEventListener("keydown", this.checkKeyDown);
    },
    onHide: function() {
      document.removeEventListener("keydown", this.checkKeyDown);
    },
    checkKeyDown: function(event) {
      if (
        this.escExit &&
        (event.key === "Escape" || event.key === "Esc" || event.keyCode === 27)
      ) {
        this.$emit("cancel");
      }
    }
  }

キーコードを愚直に見て、エスケープキーであれば親にキャンセルイベントをemitします。

完成

完成版のCodeSandboxのリンク

振り返って

率直に言えば、普段何気なく実装している機能でも、まだまだやれることがたくさんあったというのは技術者としては少しショックではありましたし、単純に時間がとてもかかったので、普段の開発でどのようにこういった取り組みを持続的に進めていくかは、深く考える必要があると感じました。

ただ、とても良いと感じたことが3つあります。

コンテンツやサービスの中身が何であるか/どうあるべきかを深く考えるきっかけとなる

実装の手法以前に、そもそもこのコンテンツはどういったユーザーがアクセスし、どういった特性があるのかなど、アクセシビリティのガイドラインを意識しよう/セマンティックなマークアップを実現しようと思うと、それを深く考えることを避けて通れなくなるように感じました。これはとてもよいことではないでしょうか。

結果的にどんな人にとっても使いやすい機能やサービスになる

より多様な使われ方に対応していく過程の中で、「これをこうすることでこういう使われ方をした際の利便性が下がる」といったトレードオフが発生したケースは今回一つもなく、これからもなさそうに思いました。それどころか、今まで対応してきた使われ方での利便性もより向上しています(例えば、モーダルダイアログを開いた際に最初のinput要素にフォーカスを当てるようにしましたが、これはどんな使われ方をされたとしてもすぐ入力できるので使いやすくなっています)。

今回はあまり触れませんでしたが、カラーコントラストや、テキストの表現の仕方など、基本的にはどのようなユーザーにとってもよりわかりやすい/使いやすいものになるような改善もまだまだたくさんありそうです。

インターネットっぽくていい

どんな人がどんな使い方をしても、平等に情報やサービスにアクセスできるというのはとてもインターネットっぽい感じがあります。

最後に

まだまだ課題は多くありますが、会社のミッションを実現できるサービスづくりを進めていくために、引き続きできることからやっていきたいと思います。もしご興味ある方は、まずは素振りから始めてみてはいかがでしょうか。

参考

最後に、参考となった記事を羅列になりますが以下に記します。

明日は基盤グループの田中さんとOwners Growthの宮川さんです。お楽しみに!