😺

SPAでボタンコンポーネントをリンクにする時に気をつけること

2021/12/07に公開

この記事はVueアドベントカレンダー2021の7日目の記事です。

SPAでボタンのコンポーネントをリンクとして扱いたいときに気をつけたいことを書きます。

Vueに限った話ではないのですが、僕がVueで開発をする機会が多いのでサンプルコードではVueを使用しています。
他のフレームワークを使っている方は、適宜置き換えて考えていただければ幸いです。

ボタンのコンポーネントでリンクさせたい時ありますよね?
Vue Routerを使用していれば router.push('/foo') のように関数を実行して遷移させることができるので、コンポーネントの内部が aタグ でも buttonタグ でもリンクとして動作させることが可能です。

どちらでもできるけど aタグ を使おうぜというのが本記事の言いたいことです!

サンプル

早速ですが、サンプルを使って紹介していきます。

見た目の同じボタンが2つ並んでいますが、どちらもページ間を移動するリンクの役割を持っていてタップやクリックした時の動作も同じです。左は buttonタグ で、右は aタグ で作られています。

動作の違い

見た目と通常の動作は同じですが、この2つのボタンには以下の違いがあります。

  • 特殊キーを押しながら操作した時の動作
  • 表示されるコンテキストメニューの違い
  • スクリーンリーダーの読み上げ内容
  • 画面の左下にリンク先が表示される

以下で詳しく説明していきます。

特殊キーを押しながら操作した時の動作

aタグ であれば、command(winならcontrol)キーを押しながらクリックすれば別タブで開く、shiftを押しながらであれば別ウインドウで開くなどの動作になりますが、buttonタグ で実装している場合はこうなりません。
(実際は手間をかければ実装可能ですが素直にaタグを使うのが良いと思います)

個人的に、別タブで開く動作はよく行うのでこれが出来ないとすごくストレスになります!

表示されるコンテキストメニューの違い

表示されるコンテキストメニューの内容も変わります。

上の図のように aタグ であればリンクに最適化されたメニューになりますが、buttonタグ の場合はそうなりません。

また、下図のようにスマホで長押しした場合も aタグ の場合は「新しいタブで開く」などのメニューが出てきますが、buttonタグ の場合は文字選択になってしまいます。

スクリーンリーダーの読み上げ内容

MacのVoiceOverを使っている時のキャプチャです。
buttonタグ の場合は「ボタン」、 aタグ の場合は「リンク」と読み上げられ、閲覧済みなどの情報も一緒に読み上げてくれます。

画面の左下にリンク先が表示される

マウスカーソルを使用している場合は、ホバー時に画面の左下にリンク先のURLが表示されます。

実装について

ここまでで紹介したように、buttonタグ を使用したリンクはクリックしてページ遷移という最低限の目的は果たせますが、その他のユーザビリティやアクセシビリティが損なわれてしまいます。

ただ、普通に aタグ のリンクにしてしまうと、SPAのシームレスな画面遷移ではなくなってしまうので、Vue Routerによるページ遷移を aタグ を両立できるように実装していくコードを実装してみます。

まず、以下が buttonタグrouter.push() を行う実装で、紹介したサンプルの左のボタンの実装になります。リンクとして使わないコンポーネントであればこれでも十分だと思います。

Page01.vue
<template>
  <Button label="ページ2へ" @click="() => $router.push('/page02')" />
</template>
Button.vue
<script>
export default {
  props: {
    label: {
      type: String,
      required: true,
    },
  },

  emits: ["click"],
};
</script>

<template>
  <button type="button" class="btn" @click="$emit('click')">{{ label }}</button>
</template>

今回はリンクとしても使いたいので、propshref が渡された時は aタグ になるように v-if を使って実装します。
aタグ でクリックされた場合は preventDefault() でデフォルトの動作をキャンセルして router.push() が実行されるようにします。

Page01.vue
<template>
  <Button label="ページ2へ" href="/page02" @click="() => $router.push('/page02')" />
</template>
Button.vue
 export default {
   props: {
     label: {
       type: String,
       required: true,
     },
   },
+  href: {
+    type: String,
+  },

   emits: ["click"],

+  setup(props, { emit }) {
+    const clickLink = (e) => {
+      // 通常の動作をキャンセルしてemitでイベントを送信
+      e.preventDefault();
+      emit("click");
+    };
+
+    return {
+      clickLink,
+    };
+  },
 };
Button.vue
 <template>
-  <button type="button" class="btn" @click="$emit('click')">{{ label }}</button>
+  <a v-if="href" :href="href" class="btn" @click="clickLink">{{ label }}</a>
+  <button v-else type="button" class="btn" @click="$emit('click')">
+   {{ label }}
+  </button>
 </template>

これで aタグ でもVue Routerを使ったページ遷移になりますが、これだけだと特殊キーを押しながらの動作や右クリック時の動作はサポートできていません。

なので、以下のように左クリック以外の場合や特殊キーを押しながらボタンをクリックされた場合は、通常の aタグ の動作になるような処理を追加します。

Button.vue
   setup(props, { emit }) {
     const clickLink = (e) => {
+      // 左クリック以外 / 同時に特殊キーが押されている場合は通常のaタグの動作を行う
+      if (
+        (e.button !== undefined && e.button !== 0) ||
+        e.metaKey ||
+        e.altKey ||
+        e.ctrlKey ||
+        e.shiftKey
+      )
+        return;
+
       // 通常の動作をキャンセルしてemitでイベントを送信
       e.preventDefault();
       emit("click");
     };

     return {
       clickLink,
     };
   },

これでサンプルの右のボタンの実装になりました。

最後に

今回使用したコード全体はGitHubにアップしています。

ボタンコンポーネントを作る時に気をつけたいこととして紹介しましたが、リンクとして扱いたい箇所全般に言える話で、僕の観測範囲の中でも役割としてはリンクなんだけど divbutton になっているWebサービスは結構あります。
別タブで開きたいのに command + click が反応してくれなかったりすると結構なストレスなので、実装の参考になれば幸いです。

Discussion