【JavaScript】ドロップダウンメニュー(メガメニュー)を作る : タッチデバイスやキーボード操作対応!

JavaScript

形だけ作ろうと思えば、CSSだけでも簡単に作れてしまうドロップダウンメニュー(メガメニュー)ですが、タッチデバイスキーボード操作まで考慮すると、意外と奥が深く難しかったりします。

今回は「どんなデバイス」でも「どんな操作方法」でも、使いやすいドロップダウンメニューを作ってみます。

ドロップダウンメニューは「クリック」と「ホバー」どちらで展開するべきか?

以下の理由から、「ドロップダウンメニューはクリックで展開するべき」と個人的には考えています。
(ホバーを否定する気はなく、両者にメリデメが存在します。)

  • ホバー操作は主にPC特有の操作方法ですが、クリック操作はタッチデバイスとPCで共通の操作方法になります。どのデバイスでも操作を統一するほうが扱いやすいと考えます。
  • クリック操作はユーザーが意思を持って操作しますが、ホバー操作は偶然マウスが通過して実行されることがあります。

このような理由から、ドロップダウンメニューはクリックで展開するように実装することが個人的には多いです。
ハンバーガーメニューもアコーディオンメニューもクリックで展開されることがほとんどですが、なぜドロップダウンメニューだけホバーが主流になっているのかは少し不思議に思います…

とはいえ、ホバーでの実装を求められることや、ドロップダウンメニューのトリガーがaタグになっている場合もありますので、それぞれの実装方法をみていきましょう。

ホバーで展開するドロップダウンメニュー(メガメニュー)

まずはホバーで展開するメニューをみていきましょう。
ポイントは、ホバー操作はマウス特有の操作方法になるので、タッチデバイスとモバイルデバイスでは機能しません。タッチデバイスとモバイルデバイスにも対応していきましょう。
また、マウス操作が難しいユーザーのために、キーボードでも操作できるようにしましょう。

サンプルサイトを新しいタブで開く

PCデバイスの実装ルール

【実装ルール】

  • PCデバイスでは、キーボード操作のフォーカスでメニューを展開
  • PCデバイスでは、マウスホバーでメニューを展開

マウスホバーでメニューを展開する動作は、CSSの擬似クラス:hoverで実装することができます。

キーボード操作のフォーカスでメニューを展開する動作は、CSSの擬似クラス:focus-withinで実装することができます。キーボードのタブキーを使ってヘッダーのメニュー要素にカーソルを当ててみてください。サブメニューが展開されます。
:focus-withinを使うことで、その要素の子孫要素がフォーカスされている場合もカバーしています。

今回はホバーとフォーカスの動作をCSSのみで実装していますが、aria属性の付け外しなども行う場合にはJavaScriptが必要になってきます。
ただ、ホバーやクリックなど操作が混合する場合に、aria属性を正確にカバーするのは難易度が高く、予期せぬ動作を防ぐためにもaria属性の付け外しは行っていません。

また、モバイル(768px未満)とタッチデバイスではクリックでメニューを展開したいので、メディアクエリ@media (min-width: 768px) and (hover: hover)を使ってデバイスを限定しています。

// PC(768px以上) かつ ホバー可能なデバイスでは、ホバーとフォーカスでメニューが展開されます。
@media (min-width: 768px) and (hover: hover) {
  .p-megaMenu:hover .p-megaMenu__navigation,
  .p-megaMenu:focus-within .p-megaMenu__navigation {
    -webkit-clip-path: inset(0 -50vw);
    clip-path: inset(0 -50vw);
    visibility: visible;
  }

  .p-megaMenu:hover .p-megaMenu__open::after,
  .p-megaMenu:focus-within .p-megaMenu__open::after {
    rotate: 180deg;
  }
}

モバイル・タッチデバイスの実装ルール

【実装ルール】

  • タッチデバイスではタップでメニューを展開
  • モバイル(768px未満)ではタップでメニューを展開
  • 開いているメニューは1つまで
  • メニューが開いた状態で768pxを跨いだ場合、メニューを閉じる

ここからは少し処理が複雑になってくるため、JavaScriptが必要になってきます。
処理の流れとしては以下のようになります。それぞれ詳しく解説していきます。

  1. ドロップダウンメニューのクリックイベントが実行される。
  2. タッチデバイスとモバイルデバイスを判定する。
  3. タッチデバイスかモバイルデバイスの場合イベントを実行する。
    • 非表示になっている要素にオープンクラスのつけ外しを行い、スタイルを操作する。
    • すでに開いているメニューは閉じる。開いているメニューは1つまで
  4. 画面のリサイズを監視し、メニューが開いた状態で768pxを跨いだ場合、メニューを閉じる

タッチデバイスとモバイルデバイスを判定し、ドロップダウンメニューのクリックイベントが実行される。

タッチデバイスとモバイルデバイスのみクリックイベントを実行したいので、デバイスの判定を行います。
イメージとしては、PCでホバーを実装した際の@media (min-width: 768px) and (hover: hover) のような役割になります。
クリックイベントの発火タイミングでデバイス判定を行えるように、デバイスの判定を関数にしておきます。

// メディアクエリのブレークポイントを設定します。この値以下の画面幅をモバイルデバイスと見なします。
const breakPoint = 768;

/**
 * デバイスがタッチ対応であるかを判断します。
 * @returns {boolean} タッチ対応の場合はtrue、そうでない場合はfalseを返します。
 */
const isTouchDevice = () => {
  return (
    "ontouchstart" in window ||
    navigator.maxTouchPoints > 0 ||
    navigator.msMaxTouchPoints > 0 ||
    window.matchMedia("(pointer: coarse)").matches
  );
};

/**
 * デバイスがモバイルデバイスであるかを判断します(画面幅がbreakPoint以下の場合)。
 * @returns {boolean} モバイルデバイスの場合はtrue、そうでない場合はfalseを返します。
 */
const isMobileDevice = () => {
  return window.matchMedia(`(max-width: ${breakPoint}px)`).matches;
};

/**
 * メガメニューの開閉を制御するイベントハンドラです。
 * @param {Event} e - クリックイベントオブジェクトです。
 */
const menuToggleAction = (e) => {
  // タッチデバイスかモバイルデバイスではない場合に処理を抜けます。
  if (!isTouchDevice() && !isMobileDevice()) {
    return;
  }

  // タッチデバイスかモバイルデバイスでクリックされた時の処理
};

タッチデバイスかモバイルデバイスの場合イベントを実行する。

デバイスの判定を行い、タッチデバイスとモバイルデバイスの場合にはクリックイベントを実行します。
CSSで非表示になっている.js-megaMenu要素に、オープンクラスの付け外しを行いスタイルを調整します。
その際、複数のメニューが同時に開くのを防ぐため、すでに開いているメニューが存在しないか判定しています。

// メディアクエリのブレークポイントを設定します。この値以下の画面幅をモバイルデバイスと見なします。
const breakPoint = 768;

// メガメニューを開閉するボタンのセレクターです。
const megaMenuButtons = document.querySelectorAll(".js-button-megaMenu");

// メニューが開いている状態を示すクラス名です。
const openClass = "is-open";

/**
 * メガメニューの開閉を制御するイベントハンドラです。
 * @param {Event} e - クリックイベントオブジェクトです。
 */
const menuToggleAction = (e) => {
  // タッチデバイスかモバイルデバイスではない場合に処理を抜けます。
  if (!isTouchDevice() && !isMobileDevice()) {
    return;
  }

  // タッチデバイスかモバイルデバイスでクリックされた時の処理
  const button = e.currentTarget;
  const currentMegaMenu = button.closest(".js-megaMenu");
  const isOpened = currentMegaMenu.classList.contains(openClass);

  document.querySelectorAll(`.js-megaMenu.${openClass}`).forEach((megaMenu) => {
    if (megaMenu !== currentMegaMenu) {
      megaMenu.classList.remove(openClass);
    }
  });

  currentMegaMenu.classList.toggle(openClass, !isOpened);
};

画面のリサイズを監視し、メニューが開いた状態で768pxを跨いだ場合、メニューを閉じる

768pxを境にホバーとクリックでイベントが切り替わるため、メニューが開いた状態でブレイクポイントを跨いだ場合の不具合もカバーしておきます。

// メディアクエリのブレークポイントを設定します。この値以下の画面幅をモバイルデバイスと見なします。
const breakPoint = 768;

// 画面のリサイズイベントに応じて、特定の条件下でメガメニューをリセットする処理を設定します。
let resizeTimer;
window.addEventListener("resize", () => {
  const delayMs = 200; // リサイズイベント後のデバウンス時間(ミリ秒)
  clearTimeout(resizeTimer);
  resizeTimer = setTimeout(() => {
    if (
      !isTouchDevice() &&
      window.matchMedia(`(min-width: ${breakPoint}px)`).matches
    ) {
      resetMegaMenu();
    }
  }, delayMs);
});

ポイントになるソースコード一式

全体のソースコードは、GitHubで確認できます。

<header class="l-header">
  <nav class="p-navigation-global" aria-label="グローバルメニュー">
    <ul class="p-navigation-global__list">
      <li class="js-megaMenu p-navigation-global__list-item p-megaMenu">
        <button type="button" class="js-button-megaMenu p-megaMenu__open">当社について</button>
        <nav  class="p-megaMenu__navigation" aria-label="当社についてのサブメニュー">
          <ul class="p-megaMenu__list">
            <li class="p-megaMenu__list-item"><a href="#">会社概要</a></li>
            <li class="p-megaMenu__list-item"><a href="#">代表挨拶</a></li>
            <li class="p-megaMenu__list-item"><a href="#">沿革</a></li>
          </ul>
        </nav>
      </li>
      <li class="js-megaMenu p-navigation-global__list-item p-megaMenu">
        <button type="button" class="js-button-megaMenu p-megaMenu__open">お知らせ</button>
        <nav class="p-megaMenu__navigation" aria-label="お知らせのサブメニュー">
          <ul class="p-megaMenu__list">
            <li class="p-megaMenu__list-item"><a href="#">お知らせ一覧</a></li>
            <li class="p-megaMenu__list-item"><a href="#">ニュースリリース</a></li>
            <li class="p-megaMenu__list-item"><a href="#">採用情報</a></li>
          </ul>
        </nav>
      </li>
      <li class="p-navigation-global__list-item">
        <a class="p-navigation-global__link" href="#">お問い合わせ</a>
      </li>
    </ul>
  </nav>
</header>

.p-megaMenu.is-open .p-megaMenu__navigation {
  -webkit-clip-path: inset(0 -50vw);
  clip-path: inset(0 -50vw);
  visibility: visible;
}

.p-megaMenu.is-open .p-megaMenu__open::after {
  rotate: 180deg;
}

.p-megaMenu__open::after {
  content: "∨";
  padding-bottom: 0.2em;
  transition: rotate 0.3s;
}

.p-megaMenu__navigation {
  -webkit-clip-path: inset(0 -50vw 100%);
  background-color: #d7d7d7;
  clip-path: inset(0 -50vw 100%);
  left: 0;
  padding: 20px;
  position: absolute;
  top: 100%;
  transition: all 0.4s;
  visibility: hidden;
  width: 100%;
}

@media (min-width: 768px) and (hover: hover) {
  .p-megaMenu:hover .p-megaMenu__navigation,
  .p-megaMenu:focus-within .p-megaMenu__navigation {
    -webkit-clip-path: inset(0 -50vw);
    clip-path: inset(0 -50vw);
    visibility: visible;
  }

  .p-megaMenu:hover .p-megaMenu__open::after,
  .p-megaMenu:focus-within .p-megaMenu__open::after {
    rotate: 180deg;
  }
}
// メディアクエリのブレークポイントを設定します。この値以下の画面幅をモバイルデバイスと見なします。
const breakPoint = 768;

// メガメニューを開閉するボタンのセレクターです。
const megaMenuButtons = document.querySelectorAll(".js-button-megaMenu");

// メニューが開いている状態を示すクラス名です。
const openClass = "is-open";

/**
 * デバイスがタッチ対応であるかを判断します。
 * @returns {boolean} タッチ対応の場合はtrue、そうでない場合はfalseを返します。
 */
const isTouchDevice = () => {
  return (
    "ontouchstart" in window ||
    navigator.maxTouchPoints > 0 ||
    navigator.msMaxTouchPoints > 0 ||
    window.matchMedia("(pointer: coarse)").matches
  );
};

/**
 * デバイスがモバイルデバイスであるかを判断します(画面幅がbreakPoint以下の場合)。
 * @returns {boolean} モバイルデバイスの場合はtrue、そうでない場合はfalseを返します。
 */
const isMobileDevice = () => {
  return window.matchMedia(`(max-width: ${breakPoint}px)`).matches;
};

/**
 * メガメニューの開閉を制御するイベントハンドラです。
 * @param {Event} e - クリックイベントオブジェクトです。
 */
const menuToggleAction = (e) => {
  if (!isTouchDevice() && !isMobileDevice()) {
    return;
  }

  const button = e.currentTarget;
  const currentMegaMenu = button.closest(".js-megaMenu");
  const isOpened = currentMegaMenu.classList.contains(openClass);

  document.querySelectorAll(`.js-megaMenu.${openClass}`).forEach((megaMenu) => {
    if (megaMenu !== currentMegaMenu) {
      megaMenu.classList.remove(openClass);
    }
  });

  currentMegaMenu.classList.toggle(openClass, !isOpened);
};

// 各メガメニューボタンにクリックイベントハンドラを設定します。
megaMenuButtons.forEach((button) => {
  button.addEventListener("click", menuToggleAction);
});

/**
 * すべてのメガメニューを閉じる関数。
 */
const resetMegaMenu = () => {
  const megaMenus = document.querySelectorAll(".js-megaMenu");
  megaMenus.forEach((megaMenu) => {
    megaMenu.classList.remove(openClass);
  });
};

// 画面のリサイズイベントに応じて、特定の条件下でメガメニューをリセットする処理を設定します。
let resizeTimer;
window.addEventListener("resize", () => {
  const delayMs = 200; // リサイズイベント後のデバウンス時間(ミリ秒)
  clearTimeout(resizeTimer);
  resizeTimer = setTimeout(() => {
    if (
      window.matchMedia(`(min-width: ${breakPoint}px)`).matches
    ) {
      resetMegaMenu();
    }
  }, delayMs);
});

クリックで展開するドロップダウンメニュー(メガメニュー)

次にクリックで展開するメニューを見ていきましょう。
イベントがクリックに限定されデバイス判定が不要になるので、ホバーに比べ簡単に実装できます。

サンプルサイトを新しいタブで開く
ソースコードをみる

アクセシビリティの対応(aria属性)

クリックイベントではイベントのきっかけが限定されるため、aria属性の対応も行っています。

その他の処理は、ホバー編のタッチデバイスで紹介したクリックイベントと同じになりますので、詳しい解説は省略します。

// メガメニューを開閉するボタンのセレクターです。
const megaMenuButtons = document.querySelectorAll(".js-button-megaMenu");

// メニューが開いている状態を示すクラス名です。
const openClass = "is-open";

/**
 * メガメニューの開閉を制御するイベントハンドラです。
 * @param {Event} e - クリックイベントオブジェクトです。
 */
const menuToggleAction = (e) => {
  const button = e.currentTarget;
  const currentMegaMenu = button.closest(".js-megaMenu");
  const isOpened = currentMegaMenu.classList.contains(openClass);

  // 他のメガメニューを閉じる処理
  document.querySelectorAll(`.js-megaMenu.${openClass}`).forEach((megaMenu) => {
    if (megaMenu !== currentMegaMenu) {
      megaMenu.classList.remove(openClass);
      // 他のボタンのaria-expanded属性をfalseに設定
      megaMenu
        .querySelector(".js-button-megaMenu")
        .setAttribute("aria-expanded", "false");
    }
  });

  // 現在のメガメニューの開閉状態を切り替え
  currentMegaMenu.classList.toggle(openClass, !isOpened);

  // aria-expanded属性の値を現在の状態に応じて設定
  button.setAttribute("aria-expanded", String(!isOpened));
};
aria属性とは?

HTMLには<button><form> のように、特定の意味を持つタグがあり、これらはブラウザやアクセシビリティツールに対して、その要素の目的や振る舞いを明確に伝えます。
これらの要素をセマンティック要素といいます。逆に、divspanのように意味を持たないタグを非セマンティック要素といいます

しかし、一般的なWebサイトの全ての要素にセマンティックなHTMLタグが用意されている訳ではなく、divタグなどを使わざるおえない場合があります。その際divタグで作成した要素に、aria属性を使用することで、そのdivタグに振る舞いやアクセシビリティ上の意味を与えることができます。

aria属性の一例

  • aria-label
    • スクリーンリーダーのようなアクセシビリティ支援ツールが読み上げるためのラベルを指定する属性です。アイコンだけで特定の役割を表している要素などによく使われます。
  • aria-expanded
    • この要素が制御する要素が、展開されているか折りたたまれているかを示します。
      ハンバーガーメニューやアコーディオンメニューでよく使われます。
  • aria-controls
    • ある要素が別の要素を「操作している」ことを示す属性です。
      ハンバーガーメニューやアコーディオンメニューでよく使われます。
  • aria-hidden
    • スクリーンリーダーのようなアクセシビリティ支援ツールに見えるかどうかを決めるものです。

オープントリガーがリンクのメガメニュー

最後に「当社について」や「お知らせ」など、メニューオープンのトリガーになる要素がaタグで作られている場合を見ていきましょう。

通常、一つのタグに対して複数のクリックイベントを持たせることはできません。
ただ、一つのタグに対してホバーとクリックで複数の役割を持たせることは可能です。
「当社について」や「お知らせ」など、メニューオープンのトリガーになる要素がaタグで作られている場合には、クリックでリンク先へ遷移し、ホバーでメニューをオープンするように実装していきます。

ただ、タッチデバイスではホバー操作することはできませんので、タッチデバイスの対応も行っていきましょう。

そもそも、メニューオープンのトリガーをaタグにするべきではない…?

昨今、タッチデバイスなど様々なデバイスで閲覧する人が増えています。
自分が仕様を決めれる立場にいるのであれば、「基本的にメニューオープンのトリガーはaタグにするべきではない」と考えています。

承知の上でデザイナーやディレクターが仕様を決めているかもしれませんし、いろいろな理由があるのでしょうが、すでに仕様が決まっているものをコーディングする際は、相談や提案してみても良いかもしれません。

サンプルサイトを新しいタブで開く
ソースコードをみる

【ポイント1】リンク遷移とメニューオープンでタグを分けて役割を管理する

PCではホバーが使えるため、一つのタグに対してホバーとクリックで複数の役割を持たせることは可能ですが、タッチデバイスではタップ操作しかできませんので、一つのタグに複数の役割を持たせることは難しいです。

「当社について」を分解して、それぞれに役割を持たせます。

  • 「当社について」
    • → リンク遷移
    • → メニューオープン

こうすることで、タッチデバイスやキーボード操作でも、それぞれの役割を実行することができます。キーボードのタブ操作でフォーカスを移動してみてください。まず「当社について」全体にフォーカスがあたり、エンターキーでリンク遷移としての役割になります。次にフォーカスを移動すると「」の部分にフォーカスがあたり、エンターキーでメニューオープンとしての役割になります。

<li class="js-megaMenu p-navigation-global__list-item p-megaMenu">
  <a class="p-megaMenu__link" href="./about">当社について</a>
  <button type="button" class="js-button-megaMenu p-megaMenu__open" aria-controls="menu1" aria-expanded="false" aria-label="メニューオープン">∨</button>
  <nav id="menu1" class="p-megaMenu__navigation" aria-label="当社についてのサブメニュー">
    <ul class="p-megaMenu__list">
      <li class="p-megaMenu__list-item"><a href="#">会社概要</a></li>
      <li class="p-megaMenu__list-item"><a href="#">代表挨拶</a></li>
      <li class="p-megaMenu__list-item"><a href="#">沿革</a></li>
    </ul>
  </nav>
</li>

【ポイント2】タッチデバイスとPCで、「」の重なり順を切り替える

先ほど「当社について」と「」を、aタグとbuttonタグに分解しました。
このままでも良いのですが、PCでは「当社について」全体をホバーでメニューが開くように対応していきます。

PCでは「当社について」全体がホバー領域になるように、aタグの領域をbuttonタグに重なるように広げます。

.p-megaMenu__link {
  position: relative;
}

.p-megaMenu__link::after {
  content: "";
  height: 100%;
  left: 0;
  position: absolute;
  top: 0;
  width: calc(100% + 31px); // button要素に重なるように領域を広げます。
}

JavaScriptでデバイスの判定を行い、body要素に現在のデバイスを表すクラスを付与します。

/**
 * body要素に適切なクラスを追加または削除します。
 * タッチデバイスまたはモバイルデバイスである場合は `is-touch-or-mobile` を追加し、
 * そうでない場合は `is-touch-or-mobile` を削除して `is-pc` を追加します。
 */
const updateBodyClass = () => {
  if (isTouchDevice() || isMobileDevice()) {
    document.body.classList.add("is-touch-or-mobile");
    document.body.classList.remove("is-pc");
  } else {
    document.body.classList.remove("is-touch-or-mobile");
    document.body.classList.add("is-pc");
  }
};

PC(is-pc)とモバイル(is-touch-or-mobile)で、buttonタグの重なり順を調整します。

PCでは 「」が「当社について」の下に潜り込みます。
モバイル(is-touch-or-mobile)では「」が「当社について」の上に重なるので、「」と「当社について」をそれぞれ個別にクリックすることができます。

.p-megaMenu__open {
	position: relative;
	z-index: -1;
}

.is-touch-or-mobile .p-megaMenu__open {
	z-index: 2;
}

【ポイント2】役割がユーザーに分かりやすいように工夫する

1つのタグが複数の役割を持つ場合には、これらがどのような動きをするのか伝わる工夫をしましょう。

リンクであることが伝わる工夫

メニューオープンのトリガーになるa要素がホバーされた時には、それ自体がa要素であることがユーザーに伝わるように、下線を表すアニメーションなどをつけると役割が伝わりやすいです。

ボタンであることが伝わる工夫

例えばモバイルデバイスでは、「当社について」と「」がそれぞれ独立した要素であることが伝わるように、リンクには下線をつけ、ボタンには枠線や色をつけると役割が伝わりやすいです。
仕様に合わせて、適切なスタイルを付けましょう。

まとめ

ドロップダウンメニュー(メガメニュー)の実装についてみてきました。
様々なデバイスや操作方法に配慮することで、全てのユーザーにとってアクセスしやすく、ストレスのないWebサイトになります。
全てのユーザーがアクセスしやすいWebを目指して、私自身もまだまだ学んでいきたいと思います。

多くのユーザーが快適に使える
サイトを目指しませんか?
コーディングのご相談は無料です。
お気軽にお問い合わせください。

お問い合わせ

軽木雄太

Code.Yu(コードユー)代表。
Webサイト制作、HTMLコーディング、WordPressオリジナルテーマ開発を承っております。お気軽にご相談ください。

ブログ一覧へ戻る
ご相談は無料です
お気軽にお問い合わせください