【JavaScript】ドロップダウンメニューを実装する

javascript-icon JavaScript

こんにちは、かつコーチです。

前回は、スムーススクロールの実装を解説しました。

今回は、ナビゲーションメニューをクリックすると選択肢が下に開く、「ドロップダウンメニュー」を実装していきます。

「クリックしたら開くところまでは作れたけど、他の場所をクリックしても閉じてくれない」という悩みは、この手のUIを作るときによくぶつかる壁です。

今日は、開閉の基本から「外側クリックで閉じる」処理まで、一通り仕上げていきます。

ドロップダウンメニューとは?

よく使われる場面

ドロップダウンメニューは、ボタンやリンクをクリック(またはホバー)すると、その下に選択肢の一覧が表示されるUIパーツです。

ナビゲーションの「サービス一覧」、ユーザーアイコンをクリックすると出てくる「マイページ・ログアウト」など、さまざまな場面で使われています。

クリック開閉とホバー開閉の違い

ドロップダウンメニューには、大きく分けて2つの開き方があります。

方式挙動向いている場面
ホバー開閉マウスが乗ると開き、離れると閉じるPCサイトのシンプルなメニュー
クリック開閉クリックすると開き、もう一度押すか外側をクリックすると閉じるスマホ対応が必要なメニュー、アクセシビリティ重視のメニュー

ホバー開閉はCSSの:hoverだけでも実装できますが、スマホではホバーという概念自体が存在しないため、タップ操作が前提のサイトではクリック開閉を採用するのが基本です。

この記事では、より汎用性の高い「クリック開閉」を中心に実装していきます。

基本の実装

手順1:HTMLで開閉ボタンとメニューを用意する

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>ドロップダウンメニュー</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="dropdown">
    <button class="dropdown-toggle" aria-expanded="false">
      サービス一覧 ▾
    </button>
    <ul class="dropdown-menu">
      <li><a href="#">Web制作</a></li>
      <li><a href="#">アプリ開発</a></li>
      <li><a href="#">保守運用</a></li>
    </ul>
  </div>

  <script src="script.js"></script>
</body>
</html>

aria-expanded="false"は、スクリーンリーダーなどの支援技術に「このボタンは今、閉じた状態のメニューを制御している」ことを伝えるための属性です。

開閉状態が変わるたびに、この値もtrue/falseで切り替えるようにします。

手順2:CSSで初期状態は「非表示」にする

.dropdown {
  position: relative;
  display: inline-block;
}

.dropdown-toggle {
  padding: 10px 16px;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: white;
  cursor: pointer;
  font-size: 15px;
}

.dropdown-menu {
  position: absolute;
  top: 100%;
  left: 0;
  min-width: 160px;
  margin: 4px 0 0;
  padding: 8px 0;
  list-style: none;
  background: white;
  border: 1px solid #ddd;
  border-radius: 4px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);

  /* 初期状態は非表示 */
  display: none;
}

.dropdown-menu.is-open {
  display: block;
}

.dropdown-menu li a {
  display: block;
  padding: 8px 16px;
  color: #333;
  text-decoration: none;
}

.dropdown-menu li a:hover {
  background: #f5f5f5;
}

.dropdown-menuは初期状態でdisplay: noneにしておき、JavaScriptでis-openクラスが付いたときだけdisplay: blockにする、という構成です。

手順3:JavaScriptで開閉を切り替える

const toggleButton = document.querySelector('.dropdown-toggle');
const menu = document.querySelector('.dropdown-menu');

toggleButton.addEventListener('click', () => {
  const isOpen = menu.classList.toggle('is-open');
  toggleButton.setAttribute('aria-expanded', isOpen);
});

classList.toggle()は、クラスが付いていなければ付け、付いていれば外す、という便利なメソッドです。

戻り値として「トグル後にクラスが付いているかどうか」の真偽値が返ってくるので、それをそのままaria-expandedの更新にも使っています。

つまずきポイント:外側をクリックしても閉じない

「メニューが開きっぱなしになる」問題

ここまでの実装で「開く」動作はできましたが、実際に使ってみると、メニューの外側をクリックしても閉じてくれないことに気づきます。

私が最初にこれを実装したとき、「閉じるボタンを別に作ればいいか」と考えて、わざわざ閉じる用のボタンを追加していました。

しかし世の中のドロップダウンメニューのほとんどは、外側をクリックするだけで自然に閉じます。

これを実現するには、document全体のクリックを監視する必要があります。

❌ Before:外側クリックを考慮していない

const toggleButton = document.querySelector('.dropdown-toggle');
const menu = document.querySelector('.dropdown-menu');

toggleButton.addEventListener('click', () => {
  menu.classList.toggle('is-open');
});
// これだけだと、メニューの外側をクリックしても閉じない

✅ After:documentのクリックを監視して外側クリックで閉じる

const dropdown = document.querySelector('.dropdown');
const toggleButton = document.querySelector('.dropdown-toggle');
const menu = document.querySelector('.dropdown-menu');

toggleButton.addEventListener('click', (e) => {
  e.stopPropagation(); // documentまでクリックイベントが伝わるのを一旦止める
  const isOpen = menu.classList.toggle('is-open');
  toggleButton.setAttribute('aria-expanded', isOpen);
});

// document全体のクリックを監視する
document.addEventListener('click', (e) => {
  // クリックされた場所がdropdownの外側だったら閉じる
  if (!dropdown.contains(e.target)) {
    menu.classList.remove('is-open');
    toggleButton.setAttribute('aria-expanded', 'false');
  }
});

ポイントは2つあります。

1つ目は、document全体にもクリックイベントを仕込み、dropdown.contains(e.target)で「クリックされた場所がドロップダウンの内側かどうか」を判定していることです。

contains()は、ある要素が別の要素の内部(子孫)に含まれているかどうかを調べるメソッドで、外側クリック判定の定番パターンです。

2つ目は、開閉ボタン自体のクリックイベントでe.stopPropagation()を呼んでいる点です。

これを書かないと、ボタンをクリックした瞬間に「ボタンのクリックイベント」と「documentのクリックイベント」の両方が発火し、開いた直後に外側クリック判定で即座に閉じてしまう、という事故が起きます。

私も最初このstopPropagation()を書き忘れて、「クリックしても一瞬で閉じてしまう」という現象に半日近く悩まされました。

イベントが「どの要素からどの要素へ伝わっていくのか」を意識していなかったのが原因だったので、この経験からイベントバブリングの理解の重要性を実感しました。

応用:キーボード操作とEscキーへの対応

アクセシビリティを意識した実装

マウス操作だけでなく、キーボードでも操作できるようにしておくと、より多くのユーザーに使いやすいUIになります。

document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && menu.classList.contains('is-open')) {
    menu.classList.remove('is-open');
    toggleButton.setAttribute('aria-expanded', 'false');
    toggleButton.focus(); // 閉じた後、ボタンにフォーカスを戻す
  }
});

Escapeキーでメニューを閉じられるようにし、閉じた後はfocus()で元のボタンにフォーカスを戻すことで、キーボードだけで操作しているユーザーが迷子にならないようにしています。

複数のドロップダウンを同時に扱う場合

サイト内に複数のドロップダウンがある場合、1つを開いたら他は自動的に閉じる、という制御もよく必要になります。

const dropdowns = document.querySelectorAll('.dropdown');

dropdowns.forEach((dropdown) => {
  const button = dropdown.querySelector('.dropdown-toggle');
  const menuEl = dropdown.querySelector('.dropdown-menu');

  button.addEventListener('click', (e) => {
    e.stopPropagation();
    const isOpen = menuEl.classList.contains('is-open');

    // 一旦すべてのドロップダウンを閉じる
    dropdowns.forEach((d) => {
      d.querySelector('.dropdown-menu').classList.remove('is-open');
      d.querySelector('.dropdown-toggle').setAttribute('aria-expanded', 'false');
    });

    // クリックされたものだけ、閉じていた場合に開き直す
    if (!isOpen) {
      menuEl.classList.add('is-open');
      button.setAttribute('aria-expanded', 'true');
    }
  });
});

document.addEventListener('click', () => {
  dropdowns.forEach((d) => {
    d.querySelector('.dropdown-menu').classList.remove('is-open');
    d.querySelector('.dropdown-toggle').setAttribute('aria-expanded', 'false');
  });
});

「一旦すべて閉じてから、対象だけ開き直す」という流れにすることで、複数のドロップダウンが同時に開いたままになる事故を防いでいます。

まとめ

この記事のポイント

  • ドロップダウンメニューは、スマホ対応も考えるとクリック開閉で実装するのが基本
  • classList.toggle()で開閉クラスを切り替え、displayを制御する
  • 外側クリックで閉じるには、documentのクリックを監視しcontains()で内外を判定する
  • ボタン自体のクリックにはstopPropagation()が必須で、忘れると開いた瞬間に閉じる事故が起きる
  • Escapeキー対応やフォーカス管理を加えると、アクセシビリティの高いUIになる

次に読むべき記事

次回は、画像の遅延読み込み(Lazy Load)の実装を解説します。

→ 次の記事:画像の遅延読み込み(Lazy Load)を実装する

タイトルとURLをコピーしました