a要素のclickイベントをフックにして独自の振る舞いをさせたいという場面はよくあります。例えばスムーズにスクロールするページ内リンクや、シングルページアプリケーションにおけるリンクです。こういった独自の振る舞いを実装するには、JavaScript でclickイベントに対してevent.preventDefault()を実行してブラウザのデフォルトの振る舞いをキャンセルする必要があります。

しかし、開発者はときに、求める振る舞いの実現ばかりに注目して、その裏で無効にされた機能を見落としてしまいます。ユーザーはブラウザ本来の振る舞いを期待してウェブサイトを操作します。ユーザーの期待通りの振る舞いをさせるためには、デフォルトの振る舞いを適切に置き換える実装が求められます。

この記事では、スムーズスクロールを伴うページ内リンクの実装を例に、a要素のclickイベントに独自の振る舞いを実装する際に意識すべき点について解説します。

修飾キーの機能を活かす

まず最初に確認すべきは、ユーザーがリンクを開く操作はひとつではないということです。マウスの左ボタンでクリックするという基本的な操作の他にも、中ボタンでクリックしたり、修飾キーを併用しながらクリックしたりすることで、ブラウザをより効率的に利用できるようになっています。

clickイベントはこれらいずれの操作でも発火します。そのため場合によっては、操作方法に応じて実行する処理を切り分ける必要があります。

開発者の実装する独自の振る舞いのほとんどは、操作方法に関わらず適用されるべきではないはずです。例えばユーザーがリンク先を新しいタブで開くことを要求したのであれば、その期待通りに新しいタブで開かれるべきです。

リンクを開くための操作には、特別な振る舞いを要求するものとそうでないものがあります。a要素のclickイベントに対応する処理を切り分けるために、関係がありそうな操作方法を列挙してみます。

  • マウスの左ボタンでクリック(修飾キー無し)
  • マウスの左ボタンでクリック(修飾キー有り)
  • マウスの中ボタンでクリック
  • マウスの右ボタンでクリック
  • キーボードのEnterキーを入力(修飾キー無し)
  • キーボードのEnterキーを入力(修飾キー有り)
  • タッチデバイスにおけるシングルタップ
  • タッチデバイスにおけるロングタップ

このうち「マウスの右ボタンでクリック」と「タッチデバイスにおけるロングタップ」はclickイベントを発火しないので除外します。

残る 6 つのうち、特別な振る舞いを要求する操作は次の 3 つです。

  • マウスの左ボタンでクリック(修飾キー有り)
  • マウスの中ボタンでクリック
  • キーボードのEnterキーを入力(修飾キー有り)

これらを除く次の 3 つは通常の操作と言えます。

  • マウスの左ボタンでクリック(修飾キー無し)
  • キーボードのEnterキーを入力(修飾キー無し)
  • タッチデバイスにおけるシングルタップ

基本的には後者の通常の操作に対してのみ独自の振る舞いを実装します。特別な振る舞いを要求する操作時には何もせず、ブラウザのデフォルトの振る舞いを活かします。

これらの操作方法に対応する振る舞いは標準として定められているものではなく、実装固有の仕様です。詳しくは後述します。

次の例では、使用されたマウスのボタンの種類と修飾キーの入力状態を判別し、処理を条件分岐しています。

const isModifiedEvent = (event) => {
  return event.ctrlKey || event.shiftKey || event.altKey || event.metaKey
}

myAnchorElement.onclick = (event) => {
  if (
    event.button === 0 && // マウスの左ボタンかキーボード由来
    !isModifiedEvent(event) // 修飾キー無し
  ) {
    performOriginalAction(event)
    event.preventDefault()
  }
}

event.buttonはトリガーとなったマウスのボタンの種類を表します。左ボタン(左利き用設定がされていれば右ボタン)でクリックされたとき、またはイベントがマウス由来でない場合0になります。通常はEnterキーを入力した場合です。

修飾キーとしては「Ctrlキー」「Shiftキー」「Altキー」「Metaキー」の 4 つのキーの入力を確認しています。これは修飾キーのうち機能が割り当てられているキーを確認しているということですが、関係する仕様は OS やブラウザによって異なっています。

Metaキーは、WindowsではWindowsキー、Macではコマンドキーに当たります。

ブラウザ固有の実装をすべて調べ上げることは困難です。参考として既存の UI ライブラリの実装を参照すると、React RouterReach Routervue-routerではこの 4 つのキーの入力を確認しています(関連する一部を vue-router にコントリビュートしたのは私ですが)。それだけではいまいち信頼性に欠けるためChromium の実装も確認してみましたが、この認識で間違いないようでした。この仕様が変更されることもあるかもしれませんが、いまのところ既定路線と捉えてよいと思われます。

ただしブラウザによってはこれらのキーに機能が割り当てられていないこともあります。例えばいくつかのブラウザはAltキーが入力されていても特別な操作として扱わず、単に無視します。例ではそうした仕様の差を意識せずに、4 つの修飾キーのいずれかが入力されていれば独自の振る舞いをキャンセルするようにしています。と言うのも、ユーザーが修飾キーを入力するならそれは意図的な操作であるはずで、Altキーを併用しながら通常のクリックを意図しているとは考えにくいためです。厳密ではありませんが、機能が割り当てられているブラウザを優先する方向に考えるのが妥当だと思われます。

CSS によるスムーズスクロール

ページ内リンクをクリックしたときのスクロールを、アニメーションによってスムーズにしたいということはよくあります。これは CSS のscroll-behaviorプロパティを使えば非常に簡単に実装できます。

body {
  scroll-behavior: smooth;
}

たったこれだけの宣言で、JavaScript を一切用いることなく、ページ内リンクをクリックしたときのスクロールにアニメーションが伴うようになります。また、ページ内リンクのクリックだけでなく、可視領域外にある要素がフォーカスされたときや、ページ内検索に一致したテキストがハイライトされたときなど、スクロールが伴う場面すべてに適用されます。少し冗長にも思えますが、画面の変化がスクロールによって引き起こされたということをユーザーが理解しやすくするための手段として手軽に採用できるでしょう。

さらに特筆すべきは、このプロパティは前述したようなブラウザのデフォルトの振る舞いを損なわずに拡張できるという点です。開発者自身はブラウザの既定の振る舞いをブラックボックスにしたままに、単にスクロールをスムーズにするという設定をすれば話が収まるのです。

ただし、欠点はブラウザの実装状況がまだ十分ではないことです。加えてアニメーションはプラットフォーム固有の設定に固定されます。

このプロパティでは解決できない要件がある場合は、やはり JavaScript で独自の実装をすることになります。それでもこの選択肢は最初に検討する価値があるでしょう。

フォーカスの管理

ページ内リンクというとついスクロールばかりに注目してしまいがちです。しかしページ内リンクをクリックしたときに行われるのはスクロールだけではありません。ブラウザはまず、リンクのターゲット要素がフォーカス可能であればその要素をフォーカスします。例えば<input id="search" type="search">という検索フィールドが存在するときに<a href="#search">検索</a>というリンクをクリックすれば、その検索フィールドをフォーカスします。検索フィールドがある位置へのスクロールと同時にフォーカスの移動が行われればユーザーの操作の手間も削減できるというわけです。

HTML において、ウェブページの中で特定の要素が操作対象になっていることを「フォーカスがある」「フォーカスを受け取っている」「フォーカスされている」というように表現します。HTML 要素にはフォーカスを受け取ることができるものとそうでないものがあります。テキストフィールドやボタンなどのフォームコントロールの他、リンクなどのインタラクティブな要素はフォーカスを受け取ることができます。

対して、リンクのターゲット要素がフォーカス可能でない場合はどうでしょう。リンクをクリックするとターゲット要素の位置までスクロールしますが、その要素自体はフォーカスされません。しかしその状態からTabキーを入力すると、次にフォーカスされる要素はターゲット要素の位置を基準に決定されます。このようなフォーカス移動の基準位置のことをシーケンシャルフォーカスナビゲーションの開始点といいます。

ブラウザのデフォルトの振る舞いとしては内部的にこのような処理が行われます。独自の振る舞いを実装するのであれば、こうした機能も忘れないように代替する必要があります。そうしないと、リンクをクリックしたときにターゲット要素の位置までスクロールするものの、Tabキーを入力した際にはスクロール前の位置を基準にフォーカスが移動してしまうことになります。

ただし、シーケンシャルフォーカスナビゲーションの開始点だけを直接操作できる API は公開されていません。代わりに対象の要素をフォーカス可能にした上で、element.focus()を実行することによってフォーカスできるようになります。それによって結果的にこの振る舞いを代替できます。

ほとんどの要素はtabindex属性を指定することでフォーカス可能になります。値には一般的に0-1を指定します。どちらでも要素はフォーカス可能になりますが、0Tabキーによるフォーカスが可能であるのに対して-1ではそうでありません。デフォルトの振る舞いを代替すると考えると、この場合ではリンクがクリックされたときだけ一時的にフォーカスさせるという方がデフォルトに近いです。

const focusForcibly = (element) => {
  element.focus()

  if (document.activeElement !== element) {
    element.tabIndex = -1
    element.focus()
  }
}

ただし今回のケースではこの実装は不完全です。element.focus()を実行すると、スクロールをアニメーションさせるよりも先に対象の要素の位置まで移動してしまうためです。element.focus()には実行時にスクロールしないというオプションがありますが、残念ながら一部のブラウザにしか実装されていません。代わりにこれに似た機能を実装しておきましょう。

const focusWithoutScroll = (element) => {
  const x = window.scrollX || window.pageXOffset
  const y = window.scrollY || window.pageYOffset
  element.focus()
  window.scrollTo(x, y)
}

ここまでの解説を踏まえて、ページ内リンクのためのclickイベントのハンドラーを実装してみます。紹介してきた実装方法に加えて、スクロールをアニメーションさせるための単機能のライブラリとしてJump.jsを使用します。

import scrollSmoothly from 'jump.js'

const focusWithoutScroll = (element) => {
  const x = window.scrollX || window.pageXOffset
  const y = window.scrollY || window.pageYOffset
  element.focus()
  window.scrollTo(x, y)
}

const focusForcibly = (element, options = {}) => {
  if (options.preventScroll) {
    focusWithoutScroll(element)
  } else {
    element.focus()
  }

  if (document.activeElement !== element) {
    element.tabIndex = -1

    if (options.preventScroll) {
      focusWithoutScroll(element)
    } else {
      element.focus()
    }
  }
}

myAnchorElement.onclick = (event) => {
  if (event.button === 0 && !isModifiedEvent(event)) {
    const targetElement = document.querySelector(event.currentTarget.hash)
    scrollSmoothly(targetElement)
    focusForcibly(targetElement, { preventScroll: true })
    event.preventDefault()
  }
}

デモ:スムーズにスクロールするページ内リンク(未完成)

実際に動くページを見てみると気になるところがあるかもしれません。まだ抜け落ちている部分があるので引き続き解説していきます。

非インタラクティブ要素のフォーカスリング

先ほど実装したページ内リンクをクリックしてみると、多くのブラウザではリンクのターゲット要素にフチのようなものが表示されていることが確認できます。

ブラウザはフォーカスされている要素に対してフチのようなものを表示します
Chromeではブラーがかった青いフチが表示されます

どの要素がフォーカスされているのかをユーザーに伝えるために、ブラウザはデフォルトでこのようなスタイルを実装しています。これはフォーカスリングあるいはフォーカスインジケータと呼ばれます。これが存在しているおかげで、どの要素がユーザーとやり取りできる状態になっているのかを視覚的に理解できるというわけです。そのため、通常はフォーカスリングを取り除いてしまうことは推奨されません。特にウェブサイトをキーボードで操作するユーザーにとっては、フォーカスリングがなければその閲覧自体が困難になってしまいます。

ですが今回は例外的なケースかもしれません。なぜならこの場合、フォーカスしている目的はインタラクティブな要素とやり取りするためではなく、Tabキーによるフォーカス操作の開始点にするために過ぎないからです。キーボード入力でやり取りすることができませんから、実質的には何もフォーカスしていないのと同じと言えます。そのためこの場合ではフォーカスリングは不要でしょう。

反面、Tabキーによるフォーカスが可能な要素であれば、フォーカス操作に支障をきたさないためにフォーカスインジケータは必要です。例ではtabindex="-1"を指定しているため、Tabフォーカス可能にはなっていません。

この例では一貫してtabindex="-1"を指定することで非インタラクティブ要素をフォーカスしているので、それに該当する要素のoutlineプロパティをリセットしておきましょう。

[tabindex="-1"]:focus {
  outline: 0;
}

Tabキーによるフォーカスの開始点にするために、あえて非インタラクティブ要素にフォーカスするパターンはよくあります。要素をフォーカスすることや、フォーカスリングを表示させることの意味を理解しておけば、別のパターンの UI 要素を実装するときにも役立てられるでしょう。

URL フラグメントの書き換え

ページ内リンクをクリックしたとき、ブラウザは URL のフラグメント(ハッシュとも呼ばれます)を書き換えます。この機能による利点は大きく 2 つあります。

まず、ページ内の特定の箇所にリンクする URL ができるという点です。これにより、ブックマークしたときに特定の箇所から閲覧を再開できたり、ページを共有するときに参照箇所をより詳しく伝えられるようになります。

そして、URL が書き換えられることによってブラウザの「戻る」機能が使えるようになるという点です。例えば長いページを閲覧するときに、ページ内リンクによって特定の箇所に移動した後に「戻る」機能によって移動前の位置に戻ることができます。

この機能もまたevent.preventDefault()によってキャンセルさせてしまうため、代替の実装が必要になります。それにはhistory.pushState()を使います。location.hashを使ってもフラグメントを書き換えることはできますが、実行時にスクロールを伴ってしまいます。今回の例ではスクロールは独自に制御したいのでhistory.pushState()の方が適しています。

myAnchorElement.onclick = (event) => {
  if (event.button === 0 && !isModifiedEvent(event)) {
    history.pushState({}, '', event.currentTarget.hash)
    const targetElement = document.querySelector(event.currentTarget.hash)
    scrollSmoothly(targetElement)
    focusForcibly(targetElement, { preventScroll: true })
    event.preventDefault()
  }
}

Scroll Restorationが実装されているブラウザであれば、history.pushState()を実行するだけでブラウザの履歴操作時にスクロール位置が復元されるようにしてくれます(Internet Explorer を除く主要なブラウザには実装されています)。

注意点としては、history.pushState()の実行時における:target擬似クラスの振る舞いが仕様として定義されていないことです。ブラウザごとの実装はまちまちで、バグといっても差し支えないような動きをします。history.pushState()を使ってフラグメントを書き換えるのであれば、:target擬似クラスは使えないものだと思っておいたほうがよいでしょう。

実装のまとめ

次の例が、これまでの解説を踏まえた完全な実装です。

import scrollSmoothly from 'jump.js'

const isModifiedEvent = (event) => {
  return event.ctrlKey || event.shiftKey || event.altKey || event.metaKey
}

const focusWithoutScroll = (element) => {
  const x = window.scrollX || window.pageXOffset
  const y = window.scrollY || window.pageYOffset
  element.focus()
  window.scrollTo(x, y)
}

const focusForcibly = (element, options = {}) => {
  if (options.preventScroll) {
    focusWithoutScroll(element)
  } else {
    element.focus()
  }

  if (document.activeElement !== element) {
    element.tabIndex = -1

    if (options.preventScroll) {
      focusWithoutScroll(element)
    } else {
      element.focus()
    }
  }
}

myAnchorElement.onclick = (event) => {
  if (event.button === 0 && !isModifiedEvent(event)) {
    history.pushState({}, '', event.currentTarget.hash)
    const targetElement = document.querySelector(event.currentTarget.hash)
    scrollSmoothly(targetElement)
    focusForcibly(targetElement, { preventScroll: true })
    event.preventDefault()
  }
}

デモ:スムーズにスクロールするページ内リンク(完成)

ポイントとしては 3 点です。

  • 特別な操作時には処理をスキップする
  • リンク先の要素にフォーカスする
  • URL のフラグメントを書き換える

通常、ブラウザデフォルトのページ内リンクはこれらの機能を備えています。独自にページ内リンクを実装するのであれば、これらの機能が欠けてしまわないよう注意して実装する必要があります。単純なように思えるページ内リンクの実装ですが、実は気をつけなければならないポイントがいくつもあることを知っていただけたでしょうか。

おわりに

この記事を読んでいただいた方には、単純な機能を実装するためにいくつも配慮しなければならない点があることを煩わしく思わせてしまったかもしれません。ですがこの代替実装の繁雑さは、逆説的にブラウザの実装の周到さを示しています。

これまで解説してきた内容はブラウザに実装されている機能の再実装と言えます。ウェブ開発者には隠蔽されて意識する必要がなかった部分を、ほんの少しだけ露わにしたに過ぎません。本来私たちはブラウザの既存の実装を素直に活かせるようにデザインすることで、多くの機能がブラウザに実装されていることの恩恵を最小限の手間で得ることができます。HTML においては適切な要素選択がその基本です。HTML 要素はそれぞれに機能を持ちます。真っ当な HTML を書くということには意味があるのです。

参考資料